diff --git a/api/.env.example b/api/.env.example index 55c33bc..6b586d8 100644 --- a/api/.env.example +++ b/api/.env.example @@ -48,6 +48,10 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/ # GITLAB_BASE_URL=https://gitlab.yourcompany.com # GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4 +# Firebase Cloud Messaging (push notifications for mobile, optional) +# Path to Firebase service account JSON file +# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json + # Centrifugo (real-time notifications, optional) # CENTRIFUGO_API_URL=http://localhost:8000 # CENTRIFUGO_API_KEY=your_centrifugo_api_key_here diff --git a/api/package.json b/api/package.json index 118e3a0..ddc5a16 100644 --- a/api/package.json +++ b/api/package.json @@ -55,6 +55,7 @@ "drizzle-orm": "^0.44.4", "emailjs": "^4.0.3", "express": "4.21.0", + "firebase-admin": "^12.7.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "passport": "^0.7.0", @@ -73,4 +74,4 @@ "engines": { "node": ">=24 <25" } -} +} \ No newline at end of file diff --git a/api/src/core/Dispatcher.ts b/api/src/core/Dispatcher.ts new file mode 100644 index 0000000..57a5a2e --- /dev/null +++ b/api/src/core/Dispatcher.ts @@ -0,0 +1,4 @@ +export interface Dispatcher { + register(): void; + registerWorkers(): Promise; +} diff --git a/api/src/core/EventBus.ts b/api/src/core/EventBus.ts index 2a02f20..ab18345 100644 --- a/api/src/core/EventBus.ts +++ b/api/src/core/EventBus.ts @@ -3,10 +3,10 @@ import type { TasksSchemaTypeForSelect } from 'taskview-db-schemas'; import { $logger } from '../modules/logget'; export interface AppEvents { - 'task.created': { task: TasksSchemaTypeForSelect; userId: number }; - 'task.updated': { task: TasksSchemaTypeForSelect; changes: Record; userId: number }; - 'task.assigneesChanged': { taskId: number; userIds: number[] }; - 'task.deleted': { taskId: number; goalId: number }; + 'task.created': { task: TasksSchemaTypeForSelect; initiatorId: number }; + 'task.updated': { task: TasksSchemaTypeForSelect; changes: Record; initiatorId: number }; + 'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number }; + 'task.deleted': { taskId: number; goalId: number; initiatorId: number }; } type EventName = keyof AppEvents; diff --git a/api/src/core/JobQueue.ts b/api/src/core/JobQueue.ts index 637d976..974431f 100644 --- a/api/src/core/JobQueue.ts +++ b/api/src/core/JobQueue.ts @@ -1,5 +1,6 @@ import { PgBoss } from 'pg-boss'; import { $logger } from '../modules/logget'; +import { Database } from '../modules/db'; let boss: PgBoss | null = null; @@ -29,3 +30,24 @@ export function getJobQueue(): PgBoss { } return boss; } + +/** Cancel jobs by singletonKey — finds and deletes matching queued jobs */ +export async function cancelJobBySingletonKey(queueName: string, singletonKey: string): Promise { + if (!boss) return; + const db = Database.getInstance(); + const result = await db.query<{ id: string }>( + `SELECT id FROM pgboss.job WHERE name = $1 AND singleton_key = $2 AND state IN ('created', 'retry')`, + [queueName, singletonKey], + ); + + const count = result?.rows?.length ?? 0; + if (count === 0) { + $logger.info(`[JobQueue] Cancel: no jobs found for key="${singletonKey}"`); + return; + } + + for (const row of result!.rows) { + await boss.deleteJob(queueName, row.id); + $logger.info(`[JobQueue] Deleted job id=${row.id} key="${singletonKey}"`); + } +} diff --git a/api/src/core/all-events.ts b/api/src/core/all-events.ts index a073791..ffcf1a5 100644 --- a/api/src/core/all-events.ts +++ b/api/src/core/all-events.ts @@ -1,17 +1,20 @@ import { startJobQueue } from './JobQueue'; -import { registerNotificationEventHandlers, registerNotificationWorkers } from '../tv-modules/notifications/notifications.events'; +import type { Dispatcher } from './Dispatcher'; +import { NotificationDispatcher } from '../tv-modules/notifications/NotificationDispatcher'; +import { WebhooksDispatcher } from '../tv-modules/webhooks/WebhooksDispatcher'; + +const dispatchers: Dispatcher[] = [ + new NotificationDispatcher(), + new WebhooksDispatcher(), +]; -/** - * Register all event handlers (called on app init, before JobQueue starts) - */ export function registerAllEventHandlers() { - registerNotificationEventHandlers(); + dispatchers.forEach((d) => d.register()); } -/** - * Start JobQueue and register all job workers - */ export async function startAllWorkers() { await startJobQueue(); - await registerNotificationWorkers(); + for (const d of dispatchers) { + await d.registerWorkers(); + } } diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index 339dd48..c500f27 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -350,5 +350,27 @@ "description": [ "Added reminders, notifications, and push_subscriptions tables" ] + }, + "28": { + "version": "1.25.0", + "name": "Release 1.25.0", + "releaseDate": "20260317", + "scripts": [ + "/1.25.0/0.1.25.0.sql" + ], + "description": [ + "Added type column to notifications table" + ] + }, + "29": { + "version": "1.26.0", + "name": "Release 1.26.0", + "releaseDate": "20260320", + "scripts": [ + "/1.26.0/0.1.26.0.sql" + ], + "description": [ + "Added notification_preferences table with JSONB settings" + ] } } \ No newline at end of file diff --git a/api/src/migrations/taskview/sql/1.25.0/0.1.25.0.sql b/api/src/migrations/taskview/sql/1.25.0/0.1.25.0.sql new file mode 100644 index 0000000..e9bbb2b --- /dev/null +++ b/api/src/migrations/taskview/sql/1.25.0/0.1.25.0.sql @@ -0,0 +1,11 @@ +ALTER TABLE tasks.notifications + ADD COLUMN IF NOT EXISTS type VARCHAR(50) NOT NULL DEFAULT 'deadline'; + +CREATE TABLE IF NOT EXISTS tasks.device_tokens ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE, + token VARCHAR(500) NOT NULL, + platform VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_device_token UNIQUE (user_id, token) +); diff --git a/api/src/migrations/taskview/sql/1.25.0/1.migrate-time-to-utc.sql b/api/src/migrations/taskview/sql/1.25.0/1.migrate-time-to-utc.sql new file mode 100644 index 0000000..23eb81c --- /dev/null +++ b/api/src/migrations/taskview/sql/1.25.0/1.migrate-time-to-utc.sql @@ -0,0 +1,5 @@ +-- Convert TIMETZ columns to TIME (without timezone) +-- Existing values are converted to UTC automatically by "AT TIME ZONE 'UTC'" +ALTER TABLE tasks.tasks + ALTER COLUMN start_time TYPE TIME USING start_time AT TIME ZONE 'UTC', + ALTER COLUMN end_time TYPE TIME USING end_time AT TIME ZONE 'UTC'; diff --git a/api/src/migrations/taskview/sql/1.25.0/2.device-tokens-timezone.sql b/api/src/migrations/taskview/sql/1.25.0/2.device-tokens-timezone.sql new file mode 100644 index 0000000..4cac042 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.25.0/2.device-tokens-timezone.sql @@ -0,0 +1,2 @@ +ALTER TABLE tasks.device_tokens + ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) NOT NULL DEFAULT 'UTC'; diff --git a/api/src/migrations/taskview/sql/1.26.0/0.1.26.0.sql b/api/src/migrations/taskview/sql/1.26.0/0.1.26.0.sql new file mode 100644 index 0000000..3c202ac --- /dev/null +++ b/api/src/migrations/taskview/sql/1.26.0/0.1.26.0.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS tasks.notification_preferences ( + user_id INTEGER PRIMARY KEY REFERENCES tv_auth.users(id) ON DELETE CASCADE, + settings JSONB NOT NULL DEFAULT '{}' +); diff --git a/api/src/migrations/taskview/sql/1.27.0/0.1.27.0.sql b/api/src/migrations/taskview/sql/1.27.0/0.1.27.0.sql new file mode 100644 index 0000000..d173b60 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.27.0/0.1.27.0.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS tasks.webhooks ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE, + url VARCHAR(500) NOT NULL, + secret_encrypted VARCHAR NOT NULL, + events VARCHAR[] NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT true, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tasks.webhook_deliveries ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + webhook_id INTEGER NOT NULL REFERENCES tasks.webhooks(id) ON DELETE CASCADE, + event VARCHAR(50) NOT NULL, + payload JSONB NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + response_code INTEGER, + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_webhooks_goal_id ON tasks.webhooks(goal_id); +CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_id ON tasks.webhook_deliveries(webhook_id); diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index 3faf6f0..c27e236 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -5,6 +5,7 @@ import GoalsRoutes from '../tv-modules/goals/GoalsRoutes'; import GraphRoutes from '../tv-modules/graph/GraphRoutes'; import IntegrationsRoutes from '../tv-modules/integrations/IntegrationsRoutes'; import NotificationsRoutes from '../tv-modules/notifications/NotificationsRoutes'; +import WebhooksRoutes from '../tv-modules/webhooks/WebhooksRoutes'; import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes'; import GoalListRoutes from '../tv-modules/lists/GoalListRoutes'; import StartRoutes from '../tv-modules/start/StartRoutes'; @@ -27,6 +28,7 @@ const routes: Record = { '/module/graph': GraphRoutes, '/module/integrations': IntegrationsRoutes, '/module/notifications': NotificationsRoutes, + '/module/webhooks': WebhooksRoutes, }; export default routes; diff --git a/api/src/tv-modules/kanban/KanbanController.ts b/api/src/tv-modules/kanban/KanbanController.ts index 13f61c5..d2df594 100644 --- a/api/src/tv-modules/kanban/KanbanController.ts +++ b/api/src/tv-modules/kanban/KanbanController.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express'; import { KanbanArkTypeFetchTasksForColumn, + KanbanArkTypeFilters, KanbanArkTypeGetTasksOrderForColumnAndCursor, KanbanArkTypeUpdateTasksOrder, KanbanSchemaAddStatus, @@ -53,7 +54,12 @@ export class KanbanController { return res.status(400).send(data.summary); } - return res.tvJson(await req.appUser.kanbanManager.fetchTasksForColumn(data)); + const filters = KanbanArkTypeFilters(req.query); + if (filters instanceof ArkErrors) { + return res.status(400).send(filters.summary); + } + + return res.tvJson(await req.appUser.kanbanManager.fetchTasksForColumn({ ...data, filters })); }; diff --git a/api/src/tv-modules/kanban/KanbanManager.ts b/api/src/tv-modules/kanban/KanbanManager.ts index 5676914..a8d55ce 100644 --- a/api/src/tv-modules/kanban/KanbanManager.ts +++ b/api/src/tv-modules/kanban/KanbanManager.ts @@ -6,6 +6,7 @@ import { type DeleteKanbanStatus, type KanbanAddStatus, type KanbanArgFetchTasksForColumn, + type KanbanArgFilters, type KanbanArgGetTasksOrderForColumnAndCursor, type KanbanArgUpdateTasksOrder, type KanbanStatusForClient, @@ -44,7 +45,7 @@ export class KanbanManager { return KanbanStatusToClientSchema.parse(status); } - async fetchTasksForColumn(data: KanbanArgFetchTasksForColumn): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null, columnVersion: number | null }> { + async fetchTasksForColumn(data: KanbanArgFetchTasksForColumn & { filters?: KanbanArgFilters }): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null, columnVersion: number | null }> { const [tasks, columnVersion] = await Promise.all([ this.user.tasksManager.fetchTasksForKanbanColumn(data), this.repository.getColumnVersion(data.goalId, data.columnId) @@ -176,8 +177,7 @@ export class KanbanManager { } } } else { - // первая задача в колонке - newOrder = KANBAN_GAP; + newOrder = await this.user.tasksManager.repository.getNextKanbanOrder(data.goalId); } if (newOrder !== null) { diff --git a/api/src/tv-modules/kanban/types.ts b/api/src/tv-modules/kanban/types.ts index 65b9c89..1a2f0bc 100644 --- a/api/src/tv-modules/kanban/types.ts +++ b/api/src/tv-modules/kanban/types.ts @@ -54,14 +54,28 @@ export const KanbanArkTypeStatusToClient = type({ export type KanbanStatusClient = typeof KanbanArkTypeStatusToClient.infer; +const NullableNumberFromString = type('string|number|null').pipe((v) => (v === 'null' || v === null || v === undefined) ? null : Number(v)); + export const KanbanArkTypeFetchTasksForColumn = type({ goalId: NumberFromString, - columnId: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)), - cursor: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)), + columnId: NullableNumberFromString, + cursor: NullableNumberFromString, }); export type KanbanArgFetchTasksForColumn = typeof KanbanArkTypeFetchTasksForColumn.infer; +const NumberArrayFromCommaSeparatedString = type('string|undefined').pipe((v) => { + if (!v) return []; + return v.split(',').map(Number).filter((n) => !isNaN(n)); +}); + +export const KanbanArkTypeFilters = type({ + 'listIds?': NumberArrayFromCommaSeparatedString, + 'assigneeIds?': NumberArrayFromCommaSeparatedString, +}); + +export type KanbanArgFilters = typeof KanbanArkTypeFilters.infer; + export const KanbanArkTypeGetTasksOrderForColumnAndCursor = type({ goalId: NumberFromString, columnId: type('string|number|null').pipe((v) => (v === 'null' || v === null) ? null : Number(v)), diff --git a/api/src/tv-modules/notifications/NotificationDispatcher.ts b/api/src/tv-modules/notifications/NotificationDispatcher.ts new file mode 100644 index 0000000..31e9fb5 --- /dev/null +++ b/api/src/tv-modules/notifications/NotificationDispatcher.ts @@ -0,0 +1,164 @@ +import { eq, and, or, isNull, inArray } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; +import { TasksSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas'; +import { eventBus, type AppEvents } from '../../core/EventBus'; +import { getJobQueue } from '../../core/JobQueue'; +import { Database } from '../../modules/db'; +import { $logger } from '../../modules/logget'; +import { getNotificationService } from './NotificationService'; +import { NotificationMessages } from './NotificationMessages'; +import { NotificationsRepository } from './repositories/NotificationsRepository'; +import { DeviceTokensRepository } from './repositories/DeviceTokensRepository'; +import { DeadlineScheduler } from './schedulers/DeadlineScheduler'; +import { NotificationType } from './types'; +import { parseUtcTime } from './utils'; +import type { Dispatcher } from '../../core/Dispatcher'; + +const CLEANUP_JOB = 'notifications-cleanup'; +const NOTIFICATIONS_RETENTION_DAYS = 1; + +export class NotificationDispatcher implements Dispatcher { + private readonly deadlineScheduler = new DeadlineScheduler(); + private readonly notificationsRepo = new NotificationsRepository(); + private readonly deviceTokensRepo = new DeviceTokensRepository(); + + register(): void { + eventBus.on('task.created', (data) => this.onTaskCreated(data)); + eventBus.on('task.updated', (data) => this.onTaskUpdated(data)); + eventBus.on('task.assigneesChanged', (data) => this.onAssigneesChanged(data)); + eventBus.on('task.deleted', (data) => this.onTaskDeleted(data)); + } + + async registerWorkers(): Promise { + await this.cleanupWorker(); + await this.deadlineScheduler.registerWorker(); + } + + private async cleanupWorker(): Promise { + const boss = getJobQueue(); + await boss.createQueue(CLEANUP_JOB); + await boss.schedule(CLEANUP_JOB, '0 3 * * *'); + await boss.work(CLEANUP_JOB, async () => { + await this.notificationsRepo.deleteOlderThanDays(NOTIFICATIONS_RETENTION_DAYS); + }); + } + + private async onTaskCreated(data: AppEvents['task.created']): Promise { + if (data.task.endDate) { + await this.deadlineScheduler.schedule(data.task, data.initiatorId); + } + } + + private async onTaskUpdated(data: AppEvents['task.updated']): Promise { + if (data.changes.complete === true) { + this.notificationsRepo.deleteByTaskAndType(data.task.id, NotificationType.DEADLINE); + await this.deadlineScheduler.cancel(data.task.id); + return; + } + + const hasDeadlineChange = + data.changes.endDate !== undefined || + data.changes.endTime !== undefined; + + if (!hasDeadlineChange) return; + + $logger.info(`[NotificationDispatcher] Rescheduling deadline for task=${data.task.id}`); + + this.notificationsRepo.deleteByTaskAndType(data.task.id, NotificationType.DEADLINE); + + if (data.task.endDate) { + await this.deadlineScheduler.schedule(data.task, data.initiatorId); + } else { + await this.deadlineScheduler.cancel(data.task.id); + } + } + + private async onAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise { + if (data.userIds.length === 0) return; + + const db = Database.getInstance(); + const task = await db.dbDrizzle + .select() + .from(TasksSchema) + .where(and(eq(TasksSchema.id, data.taskId), or(eq(TasksSchema.complete, false), isNull(TasksSchema.complete)))); + + if (!task[0]) return; + + const authUsers = alias(UsersSchema, 'auth_users'); + const authUserRows = await db.dbDrizzle + .select({ userId: authUsers.id }) + .from(CollaborationUsersSchema) + .innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email)) + .where(inArray(CollaborationUsersSchema.id, data.userIds)); + + const recipientIds = authUserRows + .map((r) => r.userId) + .filter((id) => id !== data.initiatorId); + + if (recipientIds.length === 0) return; + + await this.handleAssignNotification(data, task[0], recipientIds); + await this.handleExpiredDeadlineNotification(data, task[0], recipientIds); + } + + private async handleAssignNotification( + data: AppEvents['task.assigneesChanged'], + task: typeof TasksSchema.$inferSelect, + recipientIds: number[], + ): Promise { + const initiatorName = await this.resolveUserName(data.initiatorId); + const message = NotificationMessages.assign(task.description, initiatorName); + + $logger.info(`[NotificationDispatcher] Assign notification for task=${data.taskId}, recipients=[${recipientIds.join(',')}]`); + + await getNotificationService().notifyMany( + recipientIds, + NotificationType.ASSIGN, + message, + { goalId: task.goalId, goalListId: task.goalListId }, + data.taskId, + ); + } + + private async handleExpiredDeadlineNotification( + data: AppEvents['task.assigneesChanged'], + task: typeof TasksSchema.$inferSelect, + recipientIds: number[], + ): Promise { + if (!task.endDate) return; + + const isExpired = task.endTime + ? (parseUtcTime(task.endDate, task.endTime) ?? new Date()) <= new Date() + : new Date(`${task.endDate}T00:00:00Z`) <= new Date(); + + if (!isExpired) return; + + const tz = task.owner ? await this.deviceTokensRepo.getTimezoneByUserId(task.owner) : 'UTC'; + const message = NotificationMessages.deadline(task.description, task.endDate, task.endTime, tz); + + $logger.info(`[NotificationDispatcher] Expired deadline notification for task=${data.taskId}, recipients=[${recipientIds.join(',')}]`); + + await getNotificationService().notifyMany( + recipientIds, + NotificationType.DEADLINE, + message, + { goalId: task.goalId, goalListId: task.goalListId }, + data.taskId, + ); + } + + private async onTaskDeleted(data: AppEvents['task.deleted']): Promise { + this.notificationsRepo.deleteByTaskAndType(data.taskId, NotificationType.DEADLINE); + await this.deadlineScheduler.cancel(data.taskId); + } + + private async resolveUserName(userId: number): Promise { + const db = Database.getInstance(); + const result = await db.dbDrizzle + .select({ login: UsersSchema.login }) + .from(UsersSchema) + .where(eq(UsersSchema.id, userId)) + .limit(1); + return result[0]?.login || 'Someone'; + } +} diff --git a/api/src/tv-modules/notifications/NotificationMessages.ts b/api/src/tv-modules/notifications/NotificationMessages.ts new file mode 100644 index 0000000..359921c --- /dev/null +++ b/api/src/tv-modules/notifications/NotificationMessages.ts @@ -0,0 +1,51 @@ +import type { NotificationMessage } from './types'; +import { parseUtcTime } from './utils'; + +export class NotificationMessages { + static deadline(description: string | null, endDate: string, endTime: string | null, timezone: string): NotificationMessage { + const title = `Task: ${description || 'Task'}`; + + if (endTime) { + const deadline = parseUtcTime(endDate, endTime); + if (deadline) { + const formatted = deadline.toLocaleString('en-US', { + timeZone: timezone, + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + hour12: false, + }); + return { title, body: `Deadline: ${formatted}` }; + } + } + + return { title, body: `Deadline: ${endDate}` }; + } + + static assign(taskDescription: string | null, assignedByName: string): NotificationMessage { + return { + title: `Task: ${taskDescription || 'Task'}`, + body: `Assigned to you by ${assignedByName}`, + }; + } + + static mention(taskDescription: string | null, mentionedByName: string): NotificationMessage { + return { + title: `Task: ${taskDescription || 'Task'}`, + body: `${mentionedByName} mentioned you`, + }; + } + + static comment(taskDescription: string | null, commentByName: string): NotificationMessage { + return { + title: `Task: ${taskDescription || 'Task'}`, + body: `New comment by ${commentByName}`, + }; + } + + static statusChange(taskDescription: string | null, newStatus: string): NotificationMessage { + return { + title: `Task: ${taskDescription || 'Task'}`, + body: `Status changed to ${newStatus}`, + }; + } +} diff --git a/api/src/tv-modules/notifications/NotificationProvider.ts b/api/src/tv-modules/notifications/NotificationProvider.ts new file mode 100644 index 0000000..a991105 --- /dev/null +++ b/api/src/tv-modules/notifications/NotificationProvider.ts @@ -0,0 +1,12 @@ +import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas'; +import type { NotificationChannel } from './types'; + +export interface NotificationMeta { + goalId: number; + goalListId: number | null; +} + +export interface NotificationProvider { + readonly channel: NotificationChannel; + send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise; +} diff --git a/api/src/tv-modules/notifications/NotificationService.ts b/api/src/tv-modules/notifications/NotificationService.ts new file mode 100644 index 0000000..31b27d7 --- /dev/null +++ b/api/src/tv-modules/notifications/NotificationService.ts @@ -0,0 +1,85 @@ +import { $logger } from '../../modules/logget'; +import type { NotificationMeta, NotificationProvider } from './NotificationProvider'; +import type { NotificationMessage } from './types'; +import { NotificationsRepository } from './repositories/NotificationsRepository'; +import { UserPreferencesRepository } from './repositories/UserPreferencesRepository'; +import { NotificationChannel, type NotificationType } from './types'; +import { CentrifugoProvider } from './providers/CentrifugoProvider'; +import { FCMProvider } from './providers/FCMProvider'; + +export class NotificationService { + private readonly providers: Map; + private readonly repo: NotificationsRepository; + private readonly preferences: UserPreferencesRepository; + + constructor() { + this.repo = new NotificationsRepository(); + this.preferences = new UserPreferencesRepository(); + + const providerList: NotificationProvider[] = [ + new CentrifugoProvider(), + new FCMProvider(), + ]; + + this.providers = new Map(); + for (const p of providerList) { + this.providers.set(p.channel, p); + } + } + + async notify( + userId: number, + type: NotificationType, + message: NotificationMessage, + meta: NotificationMeta, + taskId: number | null = null, + ): Promise { + $logger.info(`[NotificationService] notify user=${userId}, type=${type}, title="${message.title}"`); + + const channels = await this.preferences.getEnabledChannels(userId, type, meta.goalId); + if (channels.length === 0) { + $logger.info(`[NotificationService] All channels disabled for user=${userId}, type=${type}`); + return; + } + + const notification = await this.repo.create({ userId, taskId, type, title: message.title, body: message.body }); + if (!notification) { + $logger.warn(`[NotificationService] Failed to create notification record for user ${userId}`); + return; + } + + $logger.info(`[NotificationService] Created notification id=${notification.id}, sending to channels=[${channels.join(',')}]`); + + await Promise.allSettled( + channels + .map((channel) => this.providers.get(channel)) + .filter(Boolean) + .map((provider) => + provider!.send(userId, notification, meta).catch((err) => { + $logger.error(err, `[NotificationService] Provider "${provider!.channel}" failed for user ${userId}`); + }) + ) + ); + } + + async notifyMany( + userIds: number[], + type: NotificationType, + message: NotificationMessage, + meta: NotificationMeta, + taskId: number | null = null, + ): Promise { + await Promise.allSettled( + userIds.map((userId) => this.notify(userId, type, message, meta, taskId)) + ); + } +} + +let _instance: NotificationService | null = null; + +export function getNotificationService(): NotificationService { + if (!_instance) { + _instance = new NotificationService(); + } + return _instance; +} diff --git a/api/src/tv-modules/notifications/NotificationsController.ts b/api/src/tv-modules/notifications/NotificationsController.ts index 8839943..66cf1b8 100644 --- a/api/src/tv-modules/notifications/NotificationsController.ts +++ b/api/src/tv-modules/notifications/NotificationsController.ts @@ -1,11 +1,20 @@ import { type } from 'arktype'; import type { Request, Response } from 'express'; import { CentrifugoClient } from '../../core/CentrifugoClient'; +import { DeviceTokensRepository } from './repositories/DeviceTokensRepository'; +import { UserPreferencesRepository } from './repositories/UserPreferencesRepository'; +import { UserPreferences } from './UserPreferences'; const NotificationArkTypeMarkRead = type({ notificationId: 'number', }); +const DeviceTokenArkType = type({ + token: 'string', + platform: "'android' | 'ios'", + timezone: 'string', +}); + export class NotificationsController { fetch = async (req: Request, res: Response) => { const cursor = req.query.cursor ? Number(req.query.cursor) : undefined; @@ -24,6 +33,56 @@ export class NotificationsController { return res.tvJson(await _req.appUser.notificationsManager.markAllRead()); }; + registerDevice = async (req: Request, res: Response) => { + console.log('[registerDevice] body:', JSON.stringify(req.body)); + const out = DeviceTokenArkType(req.body); + if (out instanceof type.errors) { + console.log('[registerDevice] validation error:', out.summary); + return res.status(400).send(out.summary); + } + const userId = req.appUser.getUserData()?.id; + if (!userId) return res.status(401).send('Unauthorized'); + + const repo = new DeviceTokensRepository(); + return res.tvJson(await repo.register(userId, out.token, out.platform, out.timezone)); + }; + + unregisterDevice = async (req: Request, res: Response) => { + const { token } = req.body; + if (!token || typeof token !== 'string') { + return res.status(400).send('token is required'); + } + const userId = req.appUser.getUserData()?.id; + if (!userId) return res.status(401).send('Unauthorized'); + + const repo = new DeviceTokensRepository(); + return res.tvJson(await repo.unregister(userId, token)); + }; + + getPreferences = async (req: Request, res: Response) => { + const userId = req.appUser.getUserData()?.id; + if (!userId) return res.status(400).send('Bad request'); + + const repo = new UserPreferencesRepository(); + const prefs = await repo.load(userId); + return res.tvJson({ settings: prefs.toJSON() }); + }; + + savePreferences = async (req: Request, res: Response) => { + const userId = req.appUser.getUserData()?.id; + if (!userId) return res.status(400).send('Bad request'); + + const { settings } = req.body; + if (!settings || typeof settings !== 'object') { + return res.status(400).send('settings is required'); + } + + const repo = new UserPreferencesRepository(); + const prefs = new UserPreferences(settings); + const result = await repo.save(userId, prefs); + return res.tvJson(result); + }; + connectionToken = async (req: Request, res: Response) => { const userId = req.appUser.getUserData()?.id; if (!userId) { diff --git a/api/src/tv-modules/notifications/NotificationsManager.ts b/api/src/tv-modules/notifications/NotificationsManager.ts index 2c4fb7c..685f313 100644 --- a/api/src/tv-modules/notifications/NotificationsManager.ts +++ b/api/src/tv-modules/notifications/NotificationsManager.ts @@ -1,5 +1,5 @@ import type { AppUser } from '../../core/AppUser'; -import { NotificationsRepository } from './NotificationsRepository'; +import { NotificationsRepository } from './repositories/NotificationsRepository'; export class NotificationsManager { private readonly user: AppUser; diff --git a/api/src/tv-modules/notifications/NotificationsRoutes.ts b/api/src/tv-modules/notifications/NotificationsRoutes.ts index 0c52409..bf6ff74 100644 --- a/api/src/tv-modules/notifications/NotificationsRoutes.ts +++ b/api/src/tv-modules/notifications/NotificationsRoutes.ts @@ -21,6 +21,10 @@ export default class NotificationsRoutes implements Routable { this.router.get('/', [IsLoggedIn], this.controller.fetch); this.router.patch('/read', [IsLoggedIn], this.controller.markRead); this.router.patch('/read-all', [IsLoggedIn], this.controller.markAllRead); + this.router.get('/preferences', [IsLoggedIn], this.controller.getPreferences); + this.router.put('/preferences', [IsLoggedIn], this.controller.savePreferences); this.router.get('/connection-token', [IsLoggedIn], this.controller.connectionToken); + this.router.post('/device/register', [IsLoggedIn], this.controller.registerDevice); + this.router.post('/device/unregister', [IsLoggedIn], this.controller.unregisterDevice); } } diff --git a/api/src/tv-modules/notifications/UserPreferences.ts b/api/src/tv-modules/notifications/UserPreferences.ts new file mode 100644 index 0000000..044d030 --- /dev/null +++ b/api/src/tv-modules/notifications/UserPreferences.ts @@ -0,0 +1,124 @@ +import { + NotificationChannel, + NotificationType, + type TypeSettings, + type TypeSettingsMap, + type DeadlineTypeSettings, + type DeadlineIntervals, + type SettingsJson, +} from './types'; + +const ALL_CHANNELS: NotificationChannel[] = [ + NotificationChannel.PUSH, + NotificationChannel.WEBSOCKET +]; + +/** + * Manages user notification preferences. + * Opt-out model: undefined = enabled. + * + * Priority: + * 1. projects[goalId][type] — highest + * 2. global[type] — fallback + * 3. undefined — enabled + */ +export class UserPreferences { + private data: SettingsJson; + + constructor(json: unknown) { + this.data = (json && typeof json === 'object' ? json : {}) as SettingsJson; + } + + getEnabledChannels(type: NotificationType, goalId?: number): NotificationChannel[] { + const resolved = this.resolveTypeSettings(type, goalId); + if (!resolved?.channels) return ALL_CHANNELS; + return ALL_CHANNELS.filter((ch) => resolved.channels![ch] !== false); + } + + isChannelEnabled(type: NotificationType, channel: NotificationChannel, goalId?: number): boolean { + const resolved = this.resolveTypeSettings(type, goalId); + return resolved?.channels?.[channel] !== false; + } + + getDeadlineIntervals(goalId?: number): DeadlineIntervals | undefined { + const resolved = this.resolveTypeSettings(NotificationType.DEADLINE, goalId) as DeadlineTypeSettings | undefined; + return resolved?.intervals; + } + + getEnabledDeadlineIntervals(goalId?: number): number[] { + const intervals = this.getDeadlineIntervals(goalId); + if (!intervals) return [0]; // default: notify at deadline + return Object.entries(intervals) + .filter(([, enabled]) => enabled !== false) + .map(([minutes]) => Number(minutes)); + } + + getGlobalTypeSettings(type: T): TypeSettingsMap[T] | undefined { + return this.data.global?.[type] as TypeSettingsMap[T] | undefined; + } + + getProjectTypeSettings(goalId: number, type: T): TypeSettingsMap[T] | undefined { + return this.data.projects?.[String(goalId)]?.[type] as TypeSettingsMap[T] | undefined; + } + + setGlobalChannel(type: NotificationType, channel: NotificationChannel, enabled: boolean): void { + if (!this.data.global) this.data.global = {}; + if (!this.data.global[type]) this.data.global[type] = {} as TypeSettingsMap[typeof type]; + if (!this.data.global[type]!.channels) this.data.global[type]!.channels = {}; + this.data.global[type]!.channels![channel] = enabled; + } + + setDeadlineIntervals(intervals: DeadlineIntervals, goalId?: number): void { + if (goalId !== undefined) { + const key = String(goalId); + if (!this.data.projects) this.data.projects = {}; + if (!this.data.projects[key]) this.data.projects[key] = {}; + if (!this.data.projects[key][NotificationType.DEADLINE]) this.data.projects[key][NotificationType.DEADLINE] = {}; + (this.data.projects[key][NotificationType.DEADLINE] as DeadlineTypeSettings).intervals = intervals; + } else { + if (!this.data.global) this.data.global = {}; + if (!this.data.global[NotificationType.DEADLINE]) this.data.global[NotificationType.DEADLINE] = {}; + (this.data.global[NotificationType.DEADLINE] as DeadlineTypeSettings).intervals = intervals; + } + } + + setProjectChannel(goalId: number, type: NotificationType, channel: NotificationChannel, enabled: boolean): void { + const key = String(goalId); + if (!this.data.projects) this.data.projects = {}; + if (!this.data.projects[key]) this.data.projects[key] = {}; + if (!this.data.projects[key][type]) this.data.projects[key][type] = {} as TypeSettingsMap[typeof type]; + if (!this.data.projects[key][type]!.channels) this.data.projects[key][type]!.channels = {}; + this.data.projects[key][type]!.channels![channel] = enabled; + } + + removeProjectSettings(goalId: number): void { + delete this.data.projects?.[String(goalId)]; + } + + toJSON(): SettingsJson { + return this.data; + } + + private resolveTypeSettings(type: NotificationType, goalId?: number): TypeSettings | undefined { + const globalSettings = this.data.global?.[type]; + + if (goalId === undefined) return globalSettings; + + const projectSettings = this.data.projects?.[String(goalId)]?.[type]; + if (!projectSettings) return globalSettings; + if (!globalSettings) return projectSettings; + + return { + channels: { ...globalSettings.channels, ...projectSettings.channels }, + ...('intervals' in globalSettings || 'intervals' in projectSettings + ? { + intervals: { + ...(globalSettings as DeadlineTypeSettings).intervals, + ...(projectSettings as DeadlineTypeSettings).intervals, + } + } + : {} + ), + }; + } +} diff --git a/api/src/tv-modules/notifications/notifications.events.ts b/api/src/tv-modules/notifications/notifications.events.ts deleted file mode 100644 index 358f382..0000000 --- a/api/src/tv-modules/notifications/notifications.events.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { eventBus } from '../../core/EventBus'; -import { getJobQueue } from '../../core/JobQueue'; -import { NotificationsRepository } from './NotificationsRepository'; -import { getCentrifugoClient } from '../../core/CentrifugoClient'; -import { $logger } from '../../modules/logget'; -import { Database } from '../../modules/db'; -import { eq, and, or, isNull } from 'drizzle-orm'; -import { alias } from 'drizzle-orm/pg-core'; -import { TasksSchema, TasksAssigneeSchema, GoalsSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas'; - -const DEADLINE_JOB = 'deadline-notification'; -const CLEANUP_JOB = 'notifications-cleanup'; -const NOTIFICATIONS_RETENTION_DAYS = 1; - -interface DeadlineJobData { - taskId: number; - description: string; - goalId: number; - goalListId: number | null; - owner: number; - endDate: string; - endTime: string | null; - initiatorId: number | null; -} - -async function scheduleDeadlineJob(task: { id: number; description: string | null; goalId: number; goalListId: number | null; owner: number | null; endDate: string | null; endTime: string | null }, initiatorId?: number) { - if (!task.endDate) return; - - const boss = getJobQueue(); - const jobKey = `deadline-${task.id}`; - const startAfter = new Date(task.endDate); - - if (task.endTime) { - const [h, m] = task.endTime.split(':'); - startAfter.setHours(Number(h), Number(m), 0, 0); - } - - const now = new Date(); - await boss.send(DEADLINE_JOB, { - taskId: task.id, - description: task.description, - goalId: task.goalId, - goalListId: task.goalListId, - owner: task.owner, - endDate: task.endDate, - endTime: task.endTime, - initiatorId: initiatorId ?? null, - }, { - startAfter: startAfter >= now ? startAfter : undefined, - singletonKey: jobKey, - }); - $logger.info({ taskId: task.id, startAfter }, '[Notifications] Deadline job scheduled'); -} - -export function registerNotificationEventHandlers() { - const repo = new NotificationsRepository(); - - // New task created with deadline - eventBus.on('task.created', async (data) => { - if (data.task.endDate) { - await scheduleDeadlineJob(data.task, data.userId); - } - }); - - // Deadline changed on existing task - eventBus.on('task.updated', async (data) => { - const hasDeadlineChange = - data.changes.endDate !== undefined || - data.changes.startDate !== undefined || - data.changes.endTime !== undefined || - data.changes.startTime !== undefined; - - if (!hasDeadlineChange) return; - - repo.deleteDeadlineNotifications(data.task.id); - await scheduleDeadlineJob(data.task, data.userId); - }); - - // Assignees changed — reschedule deadline job so new users get notified - eventBus.on('task.assigneesChanged', async (data) => { - const db = Database.getInstance(); - const task = await db.dbDrizzle - .select() - .from(TasksSchema) - .where(eq(TasksSchema.id, data.taskId)); - - if (task[0]?.endDate) { - await scheduleDeadlineJob(task[0]); - } - }); -} - -export async function registerNotificationWorkers() { - const boss = getJobQueue(); - const repo = new NotificationsRepository(); - const db = Database.getInstance(); - - await boss.createQueue(DEADLINE_JOB); - await boss.createQueue(CLEANUP_JOB); - - // Worker: process deadline notifications - await boss.work(DEADLINE_JOB, async ([job]) => { - $logger.info({ jobData: job.data }, '[Notifications] Deadline worker received job'); - const { taskId, description, goalId, goalListId, endDate, endTime, initiatorId } = job.data; - - // Check if task is still active (not completed or deleted) - const task = await db.dbDrizzle - .select({ owner: TasksSchema.owner }) - .from(TasksSchema) - .where(and(eq(TasksSchema.id, taskId), or(eq(TasksSchema.complete, false), isNull(TasksSchema.complete)))); - - $logger.info({ taskId, taskResult: task }, '[Notifications] Task lookup result'); - if (task.length === 0) return; - - const taskOwner = task[0].owner; - - // Collect all recipients: task owner + assignees (resolved to auth user id) + goal owner - let assigneesResult: { userId: number }[] = []; - let goalResult: { owner: number }[] = []; - const authUsers = alias(UsersSchema, 'auth_users'); - try { - [assigneesResult, goalResult] = await Promise.all([ - db.dbDrizzle - .select({ userId: authUsers.id }) - .from(TasksAssigneeSchema) - .innerJoin(CollaborationUsersSchema, eq(TasksAssigneeSchema.collabUserId, CollaborationUsersSchema.id)) - .innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email)) - .where(eq(TasksAssigneeSchema.taskId, taskId)), - db.dbDrizzle - .select({ owner: GoalsSchema.owner }) - .from(GoalsSchema) - .where(eq(GoalsSchema.id, goalId)), - ]); - } catch (err) { - $logger.error(err, '[Notifications] Failed to resolve recipients'); - } - - const recipientIds = new Set(); - if (taskOwner) recipientIds.add(taskOwner); - assigneesResult.forEach((r) => recipientIds.add(r.userId)); - if (goalResult[0]) { - recipientIds.add(goalResult[0].owner); - } - - // Don't notify the person who made the change - if (initiatorId) recipientIds.delete(initiatorId); - - $logger.info({ taskId, recipientIds: [...recipientIds], assignees: assigneesResult, goalOwner: goalResult[0]?.owner }, '[Notifications] Recipients resolved'); - - const title = `Deadline: ${description || 'Task'}`; - const timeStr = endTime ? `${endDate} ${endTime}` : endDate; - const body = `Deadline approaching: ${timeStr}`; - - for (const userId of recipientIds) { - const notification = await repo.create({ - userId, - taskId, - title, - body, - }); - - if (notification) { - await getCentrifugoClient().publishToUser(userId, 'notification', { - notification, - goalId, - goalListId, - }); - } - } - - $logger.info({ taskId, recipients: [...recipientIds] }, '[Notifications] Deadline notifications sent'); - }); - - // Schedule periodic cleanup of old notifications - await boss.schedule(CLEANUP_JOB, '0 3 * * *'); // daily at 3:00 AM - - await boss.work(CLEANUP_JOB, async (_jobs) => { - await repo.deleteOlderThanDays(NOTIFICATIONS_RETENTION_DAYS); - $logger.info('[Notifications] Old notifications cleaned up'); - }); -} diff --git a/api/src/tv-modules/notifications/providers/CentrifugoProvider.ts b/api/src/tv-modules/notifications/providers/CentrifugoProvider.ts new file mode 100644 index 0000000..528d9c4 --- /dev/null +++ b/api/src/tv-modules/notifications/providers/CentrifugoProvider.ts @@ -0,0 +1,16 @@ +import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas'; +import { getCentrifugoClient } from '../../../core/CentrifugoClient'; +import type { NotificationMeta, NotificationProvider } from '../NotificationProvider'; +import { NotificationChannel } from '../types'; + +export class CentrifugoProvider implements NotificationProvider { + readonly channel = NotificationChannel.WEBSOCKET; + + async send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise { + await getCentrifugoClient().publishToUser(userId, 'notification', { + notification, + goalId: meta.goalId, + goalListId: meta.goalListId, + }); + } +} diff --git a/api/src/tv-modules/notifications/providers/FCMProvider.ts b/api/src/tv-modules/notifications/providers/FCMProvider.ts new file mode 100644 index 0000000..2395c66 --- /dev/null +++ b/api/src/tv-modules/notifications/providers/FCMProvider.ts @@ -0,0 +1,107 @@ +import admin from 'firebase-admin'; +import { getMessaging } from 'firebase-admin/messaging'; +import type { NotificationsSchemaTypeForSelect } from 'taskview-db-schemas'; +import type { NotificationMeta, NotificationProvider } from '../NotificationProvider'; +import { NotificationChannel } from '../types'; +import { DeviceTokensRepository } from '../repositories/DeviceTokensRepository'; +import { $logger } from '../../../modules/logget'; + +export class FCMProvider implements NotificationProvider { + readonly channel = NotificationChannel.PUSH; + + private static initialized = false; + private static messaging: admin.messaging.Messaging | null = null; + + private readonly repo = new DeviceTokensRepository(); + private readonly enabled: boolean; + + constructor() { + this.enabled = FCMProvider.init(); + } + + private static init(): boolean { + if (FCMProvider.initialized) return true; + + const credentialsPath = process.env.FIREBASE_CREDENTIALS_PATH; + if (!credentialsPath) { + $logger.warn('[FCM] FIREBASE_CREDENTIALS_PATH not configured — push notifications disabled'); + return false; + } + + try { + admin.initializeApp({ + credential: admin.credential.cert(credentialsPath), + }); + FCMProvider.messaging = getMessaging(); + FCMProvider.messaging.enableLegacyHttpTransport(); + FCMProvider.initialized = true; + $logger.info('[FCM] Firebase initialized with legacy HTTP/1.1 transport'); + return true; + } catch (err) { + $logger.error(err, '[FCM] Failed to initialize Firebase'); + return false; + } + } + + async send(userId: number, notification: NotificationsSchemaTypeForSelect, meta: NotificationMeta): Promise { + if (!this.enabled || !FCMProvider.messaging) return; + + const tokens = await this.repo.getByUserId(userId); + $logger.info(`[FCM] User ${userId}: found ${tokens.length} device token(s)`); + if (tokens.length === 0) return; + + const message: admin.messaging.MulticastMessage = { + tokens: tokens.map((t) => t.token), + notification: { + title: notification.title, + body: notification.body || undefined, + }, + data: { + type: notification.type, + taskId: notification.taskId ? String(notification.taskId) : '', + goalId: String(meta.goalId), + goalListId: meta.goalListId ? String(meta.goalListId) : '', + notificationId: String(notification.id), + }, + android: { + priority: 'high', + notification: { + sound: 'default', + }, + }, + apns: { + payload: { + aps: { + sound: 'default', + }, + }, + }, + }; + + try { + $logger.info(`[FCM] Sending to ${tokens.length} token(s) for user ${userId}, title="${notification.title}"`); + const response = await FCMProvider.messaging.sendEachForMulticast(message); + $logger.info(`[FCM] Result: success=${response.successCount}, failure=${response.failureCount}`); + + if (response.failureCount > 0) { + const invalidTokens: string[] = []; + response.responses.forEach((resp, idx) => { + if (!resp.success) { + const code = resp.error?.code; + if (code === 'messaging/invalid-registration-token' || code === 'messaging/registration-token-not-registered') { + invalidTokens.push(tokens[idx].token); + } else { + $logger.error(resp.error, '[FCM] Failed to send to token'); + } + } + }); + + for (const token of invalidTokens) { + await this.repo.deleteByToken(token); + } + } + } catch (err) { + $logger.error(err, `[FCM] Failed to send multicast for user ${userId}`); + } + } +} diff --git a/api/src/tv-modules/notifications/repositories/DeviceTokensRepository.ts b/api/src/tv-modules/notifications/repositories/DeviceTokensRepository.ts new file mode 100644 index 0000000..598dd8f --- /dev/null +++ b/api/src/tv-modules/notifications/repositories/DeviceTokensRepository.ts @@ -0,0 +1,64 @@ +import { and, eq } from 'drizzle-orm'; +import { DeviceTokensSchema, type DeviceTokensSchemaTypeForSelect } from 'taskview-db-schemas'; +import { Database } from '../../../modules/db'; +import { callWithCatch } from '../../../utils/helpers'; + +export class DeviceTokensRepository { + private readonly db: Database; + + constructor() { + this.db = Database.getInstance(); + } + + async register(userId: number, token: string, platform: string, timezone: string): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.insert(DeviceTokensSchema).values({ + userId, + token, + platform, + timezone, + }).onConflictDoUpdate({ + target: [DeviceTokensSchema.userId, DeviceTokensSchema.token], + set: { timezone }, + }) + ); + return !!result; + } + + async unregister(userId: number, token: string): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.delete(DeviceTokensSchema) + .where(and( + eq(DeviceTokensSchema.userId, userId), + eq(DeviceTokensSchema.token, token), + )) + ); + return !!result; + } + + async getByUserId(userId: number): Promise { + return await callWithCatch(() => + this.db.dbDrizzle.select() + .from(DeviceTokensSchema) + .where(eq(DeviceTokensSchema.userId, userId)) + ) || []; + } + + async getTimezoneByUserId(userId: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select({ timezone: DeviceTokensSchema.timezone }) + .from(DeviceTokensSchema) + .where(eq(DeviceTokensSchema.userId, userId)) + .limit(1) + ); + return result?.[0]?.timezone || 'UTC'; + } + + async deleteByToken(token: string): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.delete(DeviceTokensSchema) + .where(eq(DeviceTokensSchema.token, token)) + ); + return !!result; + } +} diff --git a/api/src/tv-modules/notifications/NotificationsRepository.ts b/api/src/tv-modules/notifications/repositories/NotificationsRepository.ts similarity index 87% rename from api/src/tv-modules/notifications/NotificationsRepository.ts rename to api/src/tv-modules/notifications/repositories/NotificationsRepository.ts index 5994e07..df572c1 100644 --- a/api/src/tv-modules/notifications/NotificationsRepository.ts +++ b/api/src/tv-modules/notifications/repositories/NotificationsRepository.ts @@ -1,7 +1,7 @@ -import { and, eq, desc, lt, like, sql } from 'drizzle-orm'; +import { and, eq, desc, lt, sql } from 'drizzle-orm'; import { NotificationsSchema, TasksSchema, type NotificationsSchemaTypeForSelect } from 'taskview-db-schemas'; -import { Database } from '../../modules/db'; -import { callWithCatch } from '../../utils/helpers'; +import { Database } from '../../../modules/db'; +import { callWithCatch } from '../../../utils/helpers'; export class NotificationsRepository { private readonly db: Database; @@ -10,11 +10,12 @@ export class NotificationsRepository { this.db = Database.getInstance(); } - async create(data: { userId: number; taskId: number | null; title: string; body: string | null }): Promise { + async create(data: { userId: number; taskId: number | null; type: string; title: string; body: string | null }): Promise { const result = await callWithCatch(() => this.db.dbDrizzle.insert(NotificationsSchema).values({ userId: data.userId, taskId: data.taskId, + type: data.type, title: data.title, body: data.body, }).returning() @@ -34,6 +35,7 @@ export class NotificationsRepository { id: NotificationsSchema.id, userId: NotificationsSchema.userId, taskId: NotificationsSchema.taskId, + type: NotificationsSchema.type, title: NotificationsSchema.title, body: NotificationsSchema.body, read: NotificationsSchema.read, @@ -62,12 +64,12 @@ export class NotificationsRepository { return !!result; } - async deleteDeadlineNotifications(taskId: number): Promise { + async deleteByTaskAndType(taskId: number, type: string): Promise { const result = await callWithCatch(() => this.db.dbDrizzle.delete(NotificationsSchema) .where(and( eq(NotificationsSchema.taskId, taskId), - like(NotificationsSchema.title, 'Deadline:%'), + eq(NotificationsSchema.type, type), )) ); return !!result; diff --git a/api/src/tv-modules/notifications/repositories/UserPreferencesRepository.ts b/api/src/tv-modules/notifications/repositories/UserPreferencesRepository.ts new file mode 100644 index 0000000..ead2034 --- /dev/null +++ b/api/src/tv-modules/notifications/repositories/UserPreferencesRepository.ts @@ -0,0 +1,42 @@ +import { eq } from 'drizzle-orm'; +import { NotificationPreferencesSchema } from 'taskview-db-schemas'; +import { Database } from '../../../modules/db'; +import { callWithCatch } from '../../../utils/helpers'; +import { UserPreferences } from '../UserPreferences'; +import type { NotificationChannel, NotificationType } from '../types'; + +export class UserPreferencesRepository { + private readonly db: Database; + + constructor() { + this.db = Database.getInstance(); + } + + async load(userId: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select({ settings: NotificationPreferencesSchema.settings }) + .from(NotificationPreferencesSchema) + .where(eq(NotificationPreferencesSchema.userId, userId)) + .limit(1) + ); + return new UserPreferences(result?.[0]?.settings); + } + + async save(userId: number, preferences: UserPreferences): Promise { + const settings = preferences.toJSON(); + const result = await callWithCatch(() => + this.db.dbDrizzle.insert(NotificationPreferencesSchema) + .values({ userId, settings }) + .onConflictDoUpdate({ + target: [NotificationPreferencesSchema.userId], + set: { settings }, + }) + ); + return !!result; + } + + async getEnabledChannels(userId: number, type: NotificationType, goalId?: number): Promise { + const prefs = await this.load(userId); + return prefs.getEnabledChannels(type, goalId); + } +} diff --git a/api/src/tv-modules/notifications/schedulers/DeadlineScheduler.ts b/api/src/tv-modules/notifications/schedulers/DeadlineScheduler.ts new file mode 100644 index 0000000..822e423 --- /dev/null +++ b/api/src/tv-modules/notifications/schedulers/DeadlineScheduler.ts @@ -0,0 +1,142 @@ +import { eq, and, or, isNull } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; +import { TasksSchema, TasksAssigneeSchema, GoalsSchema, CollaborationUsersSchema, UsersSchema } from 'taskview-db-schemas'; +import { getJobQueue, cancelJobBySingletonKey } from '../../../core/JobQueue'; +import { Database } from '../../../modules/db'; +import { $logger } from '../../../modules/logget'; +import { getNotificationService } from '../NotificationService'; +import { NotificationMessages } from '../NotificationMessages'; +import { DeviceTokensRepository } from '../repositories/DeviceTokensRepository'; +import { NotificationType, type DeadlineJobData, type TaskWithDeadline } from '../types'; +import { parseUtcTime } from '../utils'; + +const DEADLINE_JOB = 'deadline-notification'; + +export class DeadlineScheduler { + private readonly deviceTokensRepo = new DeviceTokensRepository(); + + async schedule(task: TaskWithDeadline, initiatorId?: number): Promise { + if (!task.endDate) return; + + let startAfter: Date | undefined; + + if (task.endTime) { + const deadline = parseUtcTime(task.endDate, task.endTime); + if (!deadline) return; + startAfter = deadline > new Date() ? deadline : undefined; + } else { + const deadlineDay = new Date(`${task.endDate}T00:00:00Z`); + startAfter = deadlineDay > new Date() ? deadlineDay : undefined; + } + + const data: DeadlineJobData = { + taskId: task.id, + description: task.description ?? '', + goalId: task.goalId, + goalListId: task.goalListId, + endDate: task.endDate, + endTime: task.endTime, + initiatorId: initiatorId ?? null, + immediate: !startAfter, + }; + + $logger.info(`[DeadlineScheduler] Scheduling task=${task.id} at=${startAfter?.toISOString() ?? 'immediate'}`); + + await getJobQueue().send(DEADLINE_JOB, data, { + startAfter, + singletonKey: this.singletonKey(task.id), + }); + } + + async cancel(taskId: number): Promise { + await cancelJobBySingletonKey(DEADLINE_JOB, this.singletonKey(taskId)); + } + + async registerWorker(): Promise { + const boss = getJobQueue(); + const db = Database.getInstance(); + + await boss.createQueue(DEADLINE_JOB); + + await boss.work(DEADLINE_JOB, async ([job]) => { + const { taskId, description, goalId, goalListId, endDate, endTime, initiatorId, immediate } = job.data; + + if (!taskId) return; + $logger.info(`[DeadlineScheduler] Worker: job=${job.id} task=${taskId}`); + + const task = await db.dbDrizzle + .select({ owner: TasksSchema.owner, endDate: TasksSchema.endDate, endTime: TasksSchema.endTime }) + .from(TasksSchema) + .where(and(eq(TasksSchema.id, taskId), or(eq(TasksSchema.complete, false), isNull(TasksSchema.complete)))); + + if (task.length === 0) { + $logger.info(`[DeadlineScheduler] Task ${taskId}: not found or completed`); + return; + } + + if (task[0].endDate !== endDate || task[0].endTime !== endTime) { + $logger.info(`[DeadlineScheduler] Task ${taskId}: stale job, deadline changed`); + return; + } + + const recipientIds = await this.resolveRecipients(db, taskId, goalId, task[0].owner); + if (!recipientIds || recipientIds.length === 0) { + $logger.info(`[DeadlineScheduler] Task ${taskId}: no recipients`); + return; + } + + if (immediate && initiatorId) { + const idx = recipientIds.indexOf(initiatorId); + if (idx !== -1) recipientIds.splice(idx, 1); + } + + if (recipientIds.length === 0) return; + + $logger.info(`[DeadlineScheduler] Task ${taskId}: sending to [${recipientIds.join(',')}]`); + + const tz = task[0].owner ? await this.deviceTokensRepo.getTimezoneByUserId(task[0].owner) : 'UTC'; + const message = NotificationMessages.deadline(description, endDate, endTime, tz); + + await getNotificationService().notifyMany( + recipientIds, + NotificationType.DEADLINE, + message, + { goalId, goalListId }, + taskId, + ); + }); + } + + private singletonKey(taskId: number): string { + return `deadline-${taskId}`; + } + + private async resolveRecipients(db: Database, taskId: number, goalId: number, taskOwner: number | null): Promise { + const authUsers = alias(UsersSchema, 'auth_users'); + + try { + const [assignees, goal] = await Promise.all([ + db.dbDrizzle + .select({ userId: authUsers.id }) + .from(TasksAssigneeSchema) + .innerJoin(CollaborationUsersSchema, eq(TasksAssigneeSchema.collabUserId, CollaborationUsersSchema.id)) + .innerJoin(authUsers, eq(CollaborationUsersSchema.email, authUsers.email)) + .where(eq(TasksAssigneeSchema.taskId, taskId)), + db.dbDrizzle + .select({ owner: GoalsSchema.owner }) + .from(GoalsSchema) + .where(eq(GoalsSchema.id, goalId)), + ]); + + const ids = new Set(); + if (taskOwner) ids.add(taskOwner); + assignees.forEach((r) => ids.add(r.userId)); + if (goal[0]) ids.add(goal[0].owner); + + return [...ids]; + } catch (err) { + $logger.error(err, '[DeadlineScheduler] Failed to resolve recipients'); + return null; + } + } +} diff --git a/api/src/tv-modules/notifications/types.ts b/api/src/tv-modules/notifications/types.ts new file mode 100644 index 0000000..6b61a0d --- /dev/null +++ b/api/src/tv-modules/notifications/types.ts @@ -0,0 +1,123 @@ +export enum NotificationType { + DEADLINE = 'deadline', + ASSIGN = 'assign', + MENTION = 'mention', + COMMENT = 'comment', + STATUS_CHANGE = 'status_change', +} + +export enum NotificationChannel { + PUSH = 'push', + WEBSOCKET = 'websocket', + EMAIL = 'email', +} + +export interface NotificationMessage { + title: string; + body: string; +} + +/** + * Base settings — only channels (for instant notification types) + */ +export interface BaseTypeSettings { + channels?: Partial>; +} + +/** + * Fixed intervals in minutes before deadline. + * Key = minutes, value = enabled. undefined = enabled (opt-out) + */ +export interface DeadlineIntervals { + 0?: boolean; // at deadline + 15?: boolean; // 15 min before + 30?: boolean; // 30 min before + 60?: boolean; // 1 hour before + 1440?: boolean; // 1 day before +} + +/** + * Deadline has intervals (minutes before deadline to notify) + */ +export interface DeadlineTypeSettings extends BaseTypeSettings { + intervals?: DeadlineIntervals; +} + +/** + * Instant types — only channels, no intervals + */ +export type InstantTypeSettings = BaseTypeSettings; + +/** + * Maps each notification type to its allowed settings shape + */ +export interface TypeSettingsMap { + [NotificationType.DEADLINE]: DeadlineTypeSettings; + [NotificationType.ASSIGN]: InstantTypeSettings; + [NotificationType.MENTION]: InstantTypeSettings; + [NotificationType.COMMENT]: InstantTypeSettings; + [NotificationType.STATUS_CHANGE]: InstantTypeSettings; +} + +/** + * Union of all possible type settings (for generic use) + */ +export type TypeSettings = DeadlineTypeSettings | InstantTypeSettings; + +/** + * @example + * { + * "global": { + * "deadline": { + * "channels": { "push": true, "websocket": true, "email": false }, + * "intervals": { "0": true, "15": true, "60": true, "1440": false } + * }, + * "assign": { + * "channels": { "push": true, "websocket": true } + * }, + * "mention": { + * "channels": { "push": false } + * } + * }, + * "projects": { + * "42": { + * "deadline": { + * "channels": { "push": false }, + * "intervals": { "0": true, "30": true } + * } + * } + * } + * } + * + * Result for user with these settings: + * - deadline globally: push + websocket, remind at 0/15/60 min before (1440 disabled) + * - deadline in project 42: websocket only (push overridden), remind at 0/30 min before + * - assign globally: push + websocket + * - mention globally: websocket only (push explicitly disabled) + * - comment: no settings → all channels enabled (opt-out) + */ +export interface SettingsJson { + global?: { [K in NotificationType]?: TypeSettingsMap[K] }; + projects?: Record; +} + +export interface DeadlineJobData { + taskId: number; + description: string; + goalId: number; + goalListId: number | null; + endDate: string; + endTime: string | null; + initiatorId: number | null; + immediate: boolean; +} + +export interface TaskWithDeadline { + id: number; + description: string | null; + goalId: number; + goalListId: number | null; + owner: number | null; + endDate: string | null; + endTime: string | null; +} \ No newline at end of file diff --git a/api/src/tv-modules/notifications/utils.ts b/api/src/tv-modules/notifications/utils.ts new file mode 100644 index 0000000..702c06c --- /dev/null +++ b/api/src/tv-modules/notifications/utils.ts @@ -0,0 +1,42 @@ +/** + * Parse UTC time string (HH:mm:ss) with date into a Date object + */ +export function parseUtcTime(dateStr: string, timeStr: string): Date | null { + const match = timeStr.match(/^(\d{2}):(\d{2})/); + if (!match) return null; + return new Date(`${dateStr}T${match[1]}:${match[2]}:00Z`); +} + +/** + * Convert a local hour (e.g. 9 for 09:00) in a given IANA timezone + * to a UTC Date for the specified date string (YYYY-MM-DD) + */ +export function localHourToUtc(dateStr: string, hour: number, timezone: string): Date { + try { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }); + + const utcMidnight = new Date(`${dateStr}T00:00:00Z`); + const parts = formatter.formatToParts(utcMidnight); + const tzHour = Number(parts.find(p => p.type === 'hour')?.value ?? 0); + const tzDay = Number(parts.find(p => p.type === 'day')?.value ?? 0); + const utcDay = utcMidnight.getUTCDate(); + + let offsetHours = tzHour - utcMidnight.getUTCHours(); + if (tzDay > utcDay) offsetHours += 24; + else if (tzDay < utcDay) offsetHours -= 24; + + const result = new Date(`${dateStr}T00:00:00Z`); + result.setUTCHours(hour - offsetHours, 0, 0, 0); + return result; + } catch { + const fallback = new Date(`${dateStr}T00:00:00Z`); + fallback.setUTCHours(hour, 0, 0, 0); + return fallback; + } +} + diff --git a/api/src/tv-modules/tasks/TasksController.ts b/api/src/tv-modules/tasks/TasksController.ts index 2365dd1..dd1a34f 100644 --- a/api/src/tv-modules/tasks/TasksController.ts +++ b/api/src/tv-modules/tasks/TasksController.ts @@ -186,9 +186,9 @@ export class TasksController { return res.status(400).end(); } - const subtasks = await req.appUser.tasksManager.fetchSubtasks(args.data); + // const subtasks = await req.appUser.tasksManager.fetchSubtasks(args.data); - return res.tvJson(subtasks); + return res.tvJson([]); }; /** @deprecated */ diff --git a/api/src/tv-modules/tasks/TasksManager.ts b/api/src/tv-modules/tasks/TasksManager.ts index 111d617..0f91894 100644 --- a/api/src/tv-modules/tasks/TasksManager.ts +++ b/api/src/tv-modules/tasks/TasksManager.ts @@ -39,7 +39,7 @@ import { type TaskForClientNew, type TasksArgToggleTaskUsers, } from './tasks.server.types'; -import type { KanbanArgFetchTasksForColumn } from '../kanban/types'; +import type { KanbanArgFetchTasksForColumn, KanbanArgFilters } from '../kanban/types'; type TaskFieldPermissionKey = keyof typeof TaskFieldPermissionsForEditOrCreation & keyof TasksSchemaTypeForSelect; @@ -264,6 +264,13 @@ export class TasksManager { } async updateTask(data: TaskArgUpdate): Promise<{ task: TaskForClientNew; syncFailed?: boolean } | null> { + if (data.statusId !== undefined) { + const currentTask = await this.repository.fetchTaskByIdNew(data.id); + if (currentTask && currentTask.statusId !== data.statusId) { + data.kanbanOrder = await this.repository.getNextKanbanOrder(currentTask.goalId); + } + } + const task = await this.repository.updateTask(data); if (!task) { return null; @@ -279,7 +286,7 @@ export class TasksManager { eventBus.emit('task.updated', { task, changes, - userId: this.user.getUserData()?.id as number, + initiatorId: this.user.getUserData()?.id as number, }); const tasks = await this.extendTasksWithTagsAndAssignees([task]); @@ -401,7 +408,7 @@ export class TasksManager { if (task[0]) { eventBus.emit('task.created', { task: task[0], - userId: this.user.getUserData()?.id as number, + initiatorId: this.user.getUserData()?.id as number, }); } @@ -409,7 +416,12 @@ export class TasksManager { } async deleteTaskNew(data: TaskArgDelete) { - return await this.repository.deleteTaskNew(data); + const task = await this.repository.fetchTaskByIdNew(data.taskId); + const result = await this.repository.deleteTaskNew(data); + if (result) { + eventBus.emit('task.deleted', { taskId: data.taskId, goalId: task?.goalId ?? 0, initiatorId: this.user.getUserData()?.id as number }); + } + return result; } async toggleTaskUsers(data: TasksArgToggleTaskUsers) { @@ -417,12 +429,13 @@ export class TasksManager { eventBus.emit('task.assigneesChanged', { taskId: data.taskId, userIds: data.userIds, + initiatorId: this.user.getUserData()?.id as number, }); return result; } - async fetchTasksForKanbanColumn(data: KanbanArgFetchTasksForColumn): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null }> { - const tasks = await this.repository.fetchTasksForKanbanColumn(data.goalId, data.columnId, data.cursor); + async fetchTasksForKanbanColumn(data: KanbanArgFetchTasksForColumn & { filters?: KanbanArgFilters }): Promise<{ tasks: TaskForClientNew[], nextCursor: string | number | null }> { + const tasks = await this.repository.fetchTasksForKanbanColumn(data.goalId, data.columnId, data.cursor, data.filters); if (!tasks || tasks.length === 0) return { tasks: [], nextCursor: null }; return { tasks: await this.extendTasksWithTagsAndAssignees(tasks), nextCursor: tasks[tasks.length - 1].kanbanOrder }; } diff --git a/api/src/tv-modules/tasks/TasksRepository.ts b/api/src/tv-modules/tasks/TasksRepository.ts index 7c8c00a..79a2d22 100644 --- a/api/src/tv-modules/tasks/TasksRepository.ts +++ b/api/src/tv-modules/tasks/TasksRepository.ts @@ -30,6 +30,7 @@ import type { TaskArgUpdate, TasksArgToggleTaskUsers, } from './tasks.server.types'; +import type { KanbanArgFilters } from '../kanban/types'; export class TasksRepository { private readonly db: Database; @@ -643,7 +644,7 @@ export class TasksRepository { return !!result?.rowCount; } - async fetchTasksForKanbanColumn(goalId: number, columnId: number | null, cursor: number | null): Promise { + async fetchTasksForKanbanColumn(goalId: number, columnId: number | null, cursor: number | null, filters?: KanbanArgFilters): Promise { const conditions = [ eq(TasksSchema.goalId, goalId), columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId), @@ -653,6 +654,24 @@ export class TasksRepository { if (cursor !== null) { conditions.push(gt(TasksSchema.kanbanOrder, cursor)); } + if (filters?.listIds && filters.listIds.length > 0) { + conditions.push(inArray(TasksSchema.goalListId, filters.listIds)); + } + if (filters?.assigneeIds && filters.assigneeIds.length > 0) { + conditions.push( + exists( + this.db.dbDrizzle + .select({ one: sql`1` }) + .from(TasksAssigneeSchema) + .where( + and( + eq(TasksAssigneeSchema.taskId, TasksSchema.id), + inArray(TasksAssigneeSchema.collabUserId, filters.assigneeIds) + ) + ) + ) + ); + } const result = await callWithCatch(() => this.db.dbDrizzle.select().from(TasksSchema).where(and(...conditions)).orderBy(asc(TasksSchema.kanbanOrder)).limit(20) @@ -660,11 +679,13 @@ export class TasksRepository { return result ?? []; } - async fetchTaskWithMinKanbanOrder(goalId: number, columnId: number | null): Promise { + async fetchTaskWithMinKanbanOrder(goalId: number, columnId?: number | null): Promise { const conditions = [ eq(TasksSchema.goalId, goalId), - columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId), ]; + if (columnId !== undefined) { + conditions.push(columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId)); + } const result = await callWithCatch(() => this.db.dbDrizzle.select({ minKanbanOrder: sql`MIN(kanban_order)` }).from(TasksSchema).where(and(...conditions))); @@ -673,8 +694,8 @@ export class TasksRepository { static readonly KANBAN_ORDER_GAP = 16384; - async getNextKanbanOrder(goalId: number, statusId: number | null = null): Promise { - const min = await this.fetchTaskWithMinKanbanOrder(goalId, statusId); + async getNextKanbanOrder(goalId: number, columnId?: number | null): Promise { + const min = await this.fetchTaskWithMinKanbanOrder(goalId, columnId); return (min ?? 0) - TasksRepository.KANBAN_ORDER_GAP; } } diff --git a/api/src/tv-modules/webhooks/WebhooksController.ts b/api/src/tv-modules/webhooks/WebhooksController.ts new file mode 100644 index 0000000..3e5b081 --- /dev/null +++ b/api/src/tv-modules/webhooks/WebhooksController.ts @@ -0,0 +1,93 @@ +import type { Request, Response } from 'express'; +import { ArkErrors } from 'arktype'; +import { WebhooksManager } from './WebhooksManager'; +import { + WebhookArkTypeCreate, + WebhookArkTypeUpdate, + WebhookArkTypeDelete, + WebhookArkTypeFetch, + WebhookArkTypeById, + WebhookArkTypeFetchDeliveries, +} from './types'; + +export class WebhooksController { + private readonly manager = new WebhooksManager(); + + create = async (req: Request, res: Response) => { + const data = WebhookArkTypeCreate(req.body); + if (data instanceof ArkErrors) { + return res.status(400).send(data.summary); + } + const result = await this.manager.create(data); + if (!result) return res.status(500).end(); + return res.tvJson(result); + }; + + update = async (req: Request, res: Response) => { + const data = WebhookArkTypeUpdate(req.body); + if (data instanceof ArkErrors) { + return res.status(400).send(data.summary); + } + const result = await this.manager.update(data); + if (!result) return res.status(404).end(); + return res.tvJson(result); + }; + + delete = async (req: Request, res: Response) => { + const data = WebhookArkTypeDelete(req.body); + if (data instanceof ArkErrors) { + return res.status(400).send(data.summary); + } + const result = await this.manager.delete(data.id); + return res.tvJson(result); + }; + + fetch = async (req: Request, res: Response) => { + const data = WebhookArkTypeFetch(req.query); + if (data instanceof ArkErrors) { + return res.status(400).send(data.summary); + } + const result = await this.manager.fetchByGoalId(data.goalId); + return res.tvJson(result); + }; + + rotateSecret = async (req: Request, res: Response) => { + const data = WebhookArkTypeById(req.body); + if (data instanceof ArkErrors) { + return res.status(400).send(data.summary); + } + const result = await this.manager.rotateSecret(data.id); + if (!result) return res.status(404).end(); + return res.tvJson(result); + }; + + testDelivery = async (req: Request, res: Response) => { + const data = WebhookArkTypeById(req.body); + if (data instanceof ArkErrors) { + return res.status(400).send(data.summary); + } + const result = await this.manager.testDelivery(data.id); + return res.tvJson(result); + }; + + fetchDeliveries = async (req: Request, res: Response) => { + const data = WebhookArkTypeFetchDeliveries({ ...req.params, ...req.query }); + if (data instanceof ArkErrors) { + return res.status(400).send(data.summary); + } + const result = await this.manager.fetchDeliveries(data.id, { + cursor: data.cursor, + status: data.status, + }); + return res.tvJson(result); + }; + + retryDelivery = async (req: Request, res: Response) => { + const data = WebhookArkTypeById(req.body); + if (data instanceof ArkErrors) { + return res.status(400).send(data.summary); + } + const result = await this.manager.retryDelivery(data.id); + return res.tvJson(result); + }; +} diff --git a/api/src/tv-modules/webhooks/WebhooksDispatcher.ts b/api/src/tv-modules/webhooks/WebhooksDispatcher.ts new file mode 100644 index 0000000..3dfa080 --- /dev/null +++ b/api/src/tv-modules/webhooks/WebhooksDispatcher.ts @@ -0,0 +1,102 @@ +import { eq } from 'drizzle-orm'; +import { TasksSchema } from 'taskview-db-schemas'; +import { eventBus, type AppEvents } from '../../core/EventBus'; +import { getJobQueue } from '../../core/JobQueue'; +import { decrypt } from '../../utils/crypto'; +import { $logger } from '../../modules/logget'; +import { Database } from '../../modules/db'; +import { WebhooksRepository } from './WebhooksRepository'; +import { WebhooksManager } from './WebhooksManager'; +import type { WebhookDeliverJobData } from './types'; +import type { Dispatcher } from '../../core/Dispatcher'; + +const WEBHOOK_DELIVER_JOB = 'webhook-deliver'; +const MAX_ATTEMPTS = 3; +const MAX_CONSECUTIVE_FAILURES = 10; + +export class WebhooksDispatcher implements Dispatcher { + private readonly repository = new WebhooksRepository(); + private readonly manager = new WebhooksManager(); + + register(): void { + eventBus.on('task.created', (data) => this.dispatch('task.created', data.task.goalId, data)); + eventBus.on('task.updated', (data) => this.dispatch('task.updated', data.task.goalId, data)); + eventBus.on('task.deleted', (data) => this.dispatch('task.deleted', data.goalId, data)); + eventBus.on('task.assigneesChanged', (data) => this.dispatchAssigneesChanged(data)); + } + + async registerWorkers(): Promise { + const boss = getJobQueue(); + await boss.createQueue(WEBHOOK_DELIVER_JOB); + await boss.work(WEBHOOK_DELIVER_JOB, async ([job]) => { + await this.deliverJob(job.data); + }); + } + + private async dispatch(event: string, goalId: number, payload: object): Promise { + const webhooks = await this.repository.fetchActiveByGoalIdAndEvent(goalId, event); + for (const webhook of webhooks) { + await this.enqueueDelivery(webhook.id, webhook.url, webhook.secretEncrypted, event, { + event, + timestamp: new Date().toISOString(), + ...payload, + }); + } + } + + private async dispatchAssigneesChanged(data: AppEvents['task.assigneesChanged']): Promise { + const db = Database.getInstance(); + const task = await db.dbDrizzle.select().from(TasksSchema).where(eq(TasksSchema.id, data.taskId)).limit(1); + if (!task[0]) return; + await this.dispatch('task.assigneesChanged', task[0].goalId, data); + } + + private async enqueueDelivery(webhookId: number, url: string, secretEncrypted: string, event: string, payload: object): Promise { + const delivery = await this.repository.createDelivery({ webhookId, event, payload }); + if (!delivery) return; + + const boss = getJobQueue(); + await boss.send(WEBHOOK_DELIVER_JOB, { + deliveryId: delivery.id, + webhookId, + url, + secretEncrypted, + payload, + attempt: 1, + } satisfies WebhookDeliverJobData); + } + + private async deliverJob(data: WebhookDeliverJobData): Promise { + const secret = decrypt(data.secretEncrypted); + const result = await this.manager.deliver(data.url, secret, data.payload); + + await this.repository.updateDelivery(data.deliveryId, { + status: result.success ? 'success' : (data.attempt >= MAX_ATTEMPTS ? 'failed' : 'pending'), + responseCode: result.responseCode, + attempts: data.attempt, + }); + + if (result.success) { + await this.repository.resetConsecutiveFailures(data.webhookId); + return; + } + + if (data.attempt < MAX_ATTEMPTS) { + const boss = getJobQueue(); + const delay = Math.pow(2, data.attempt) * 5; + await boss.send(WEBHOOK_DELIVER_JOB, { + ...data, + attempt: data.attempt + 1, + }, { startAfter: delay }); + return; + } + + const failures = await this.repository.incrementConsecutiveFailures(data.webhookId); + if (failures >= MAX_CONSECUTIVE_FAILURES) { + await this.repository.deactivate(data.webhookId); + $logger.warn(`[Webhooks] Deactivated webhook=${data.webhookId} after ${failures} consecutive failures`); + } else { + $logger.warn(`[Webhooks] Delivery failed for webhook=${data.webhookId}, consecutive failures: ${failures}/${MAX_CONSECUTIVE_FAILURES}`); + } + } +} diff --git a/api/src/tv-modules/webhooks/WebhooksManager.ts b/api/src/tv-modules/webhooks/WebhooksManager.ts new file mode 100644 index 0000000..d3927bb --- /dev/null +++ b/api/src/tv-modules/webhooks/WebhooksManager.ts @@ -0,0 +1,124 @@ +import { randomBytes, createHmac } from 'crypto'; +import { encrypt, decrypt } from '../../utils/crypto'; +import { $logger } from '../../modules/logget'; +import { WebhooksRepository } from './WebhooksRepository'; +import type { WebhookArgCreate, WebhookArgUpdate } from './types'; +import type { WebhooksSchemaTypeForSelect } from 'taskview-db-schemas'; + +export type WebhookForClient = Omit; + +export class WebhooksManager { + public readonly repository: WebhooksRepository; + + constructor() { + this.repository = new WebhooksRepository(); + } + + async create(data: WebhookArgCreate): Promise<{ webhook: WebhookForClient; secret: string } | null> { + const secret = randomBytes(32).toString('hex'); + const secretEncrypted = encrypt(secret); + + const webhook = await this.repository.create({ + goalId: data.goalId, + url: data.url, + secretEncrypted, + events: data.events, + }); + + if (!webhook) return null; + + return { webhook: this.toClient(webhook), secret }; + } + + async update(data: WebhookArgUpdate): Promise { + const updateData: Partial<{ url: string; events: string[]; isActive: boolean }> = {}; + if (data.url !== undefined) updateData.url = data.url; + if (data.events !== undefined) updateData.events = data.events; + if (data.isActive !== undefined) updateData.isActive = data.isActive; + + const webhook = await this.repository.update(data.id, updateData); + if (!webhook) return null; + return this.toClient(webhook); + } + + async delete(id: number): Promise { + return this.repository.delete(id); + } + + async fetchByGoalId(goalId: number): Promise { + const webhooks = await this.repository.fetchByGoalId(goalId); + return webhooks.map(w => this.toClient(w)); + } + + async rotateSecret(id: number): Promise<{ secret: string } | null> { + const secret = randomBytes(32).toString('hex'); + const secretEncrypted = encrypt(secret); + const success = await this.repository.updateSecret(id, secretEncrypted); + if (!success) return null; + return { secret }; + } + + async testDelivery(id: number): Promise<{ success: boolean; responseCode?: number }> { + const webhook = await this.repository.fetchById(id); + if (!webhook) return { success: false }; + + const secret = decrypt(webhook.secretEncrypted); + const payload = { + event: 'webhook.test', + timestamp: new Date().toISOString(), + data: { message: 'This is a test webhook delivery' }, + }; + + return this.deliver(webhook.url, secret, payload); + } + + async deliver(url: string, secret: string, payload: object): Promise<{ success: boolean; responseCode?: number }> { + const body = JSON.stringify(payload); + const signature = createHmac('sha256', secret).update(body).digest('hex'); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': `sha256=${signature}`, + }, + body, + signal: AbortSignal.timeout(10000), + }); + + return { success: response.ok, responseCode: response.status }; + } catch (err) { + $logger.error(err, `[Webhooks] Delivery failed to ${url}`); + return { success: false }; + } + } + + async retryDelivery(deliveryId: number): Promise<{ success: boolean; responseCode?: number }> { + const deliveries = await this.repository.fetchDeliveryById(deliveryId); + if (!deliveries) return { success: false }; + + const webhook = await this.repository.fetchById(deliveries.webhookId); + if (!webhook) return { success: false }; + + const secret = decrypt(webhook.secretEncrypted); + const result = await this.deliver(webhook.url, secret, deliveries.payload as object); + + await this.repository.updateDelivery(deliveryId, { + status: result.success ? 'success' : 'failed', + responseCode: result.responseCode, + attempts: deliveries.attempts + 1, + }); + + return result; + } + + async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string }) { + return this.repository.fetchDeliveries(webhookId, options); + } + + private toClient(webhook: WebhooksSchemaTypeForSelect): WebhookForClient { + const { secretEncrypted, ...rest } = webhook; + return rest; + } +} diff --git a/api/src/tv-modules/webhooks/WebhooksRepository.ts b/api/src/tv-modules/webhooks/WebhooksRepository.ts new file mode 100644 index 0000000..62bf79b --- /dev/null +++ b/api/src/tv-modules/webhooks/WebhooksRepository.ts @@ -0,0 +1,144 @@ +import { and, desc, eq, lt, sql } from 'drizzle-orm'; +import { WebhooksSchema, WebhookDeliveriesSchema, type WebhooksSchemaTypeForSelect, type WebhookDeliveriesSchemaTypeForSelect } from 'taskview-db-schemas'; +import { Database } from '../../modules/db'; +import { callWithCatch } from '../../utils/helpers'; + +export class WebhooksRepository { + private readonly db: Database; + + constructor() { + this.db = Database.getInstance(); + } + + async create(data: { goalId: number; url: string; secretEncrypted: string; events: string[] }): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.insert(WebhooksSchema).values(data).returning() + ); + return result?.[0] ?? null; + } + + async update(id: number, data: Partial<{ url: string; events: string[]; isActive: boolean }>): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(WebhooksSchema) + .set({ ...data, updatedAt: new Date() }) + .where(eq(WebhooksSchema.id, id)) + .returning() + ); + return result?.[0] ?? null; + } + + async updateSecret(id: number, secretEncrypted: string): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(WebhooksSchema) + .set({ secretEncrypted, updatedAt: new Date() }) + .where(eq(WebhooksSchema.id, id)) + .returning() + ); + return (result?.length ?? 0) > 0; + } + + async delete(id: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.delete(WebhooksSchema).where(eq(WebhooksSchema.id, id)) + ); + return !!result?.rowCount; + } + + async fetchById(id: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(WebhooksSchema).where(eq(WebhooksSchema.id, id)) + ); + return result?.[0] ?? null; + } + + async fetchByGoalId(goalId: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(WebhooksSchema).where(eq(WebhooksSchema.goalId, goalId)) + ); + return result ?? []; + } + + async fetchActiveByGoalIdAndEvent(goalId: number, event: string): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(WebhooksSchema).where( + and( + eq(WebhooksSchema.goalId, goalId), + eq(WebhooksSchema.isActive, true), + ) + ) + ); + return (result ?? []).filter(w => w.events.includes(event)); + } + + async createDelivery(data: { webhookId: number; event: string; payload: unknown }): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.insert(WebhookDeliveriesSchema).values({ + webhookId: data.webhookId, + event: data.event, + payload: data.payload, + }).returning() + ); + return result?.[0] ?? null; + } + + async updateDelivery(id: number, data: { status: string; responseCode?: number; attempts: number }): Promise { + await callWithCatch(() => + this.db.dbDrizzle.update(WebhookDeliveriesSchema) + .set({ ...data, lastAttemptAt: new Date() }) + .where(eq(WebhookDeliveriesSchema.id, id)) + ); + } + + async fetchDeliveryById(id: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(WebhookDeliveriesSchema).where(eq(WebhookDeliveriesSchema.id, id)) + ); + return result?.[0] ?? null; + } + + async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string; limit?: number }): Promise { + const limit = options?.limit ?? 20; + const conditions = [eq(WebhookDeliveriesSchema.webhookId, webhookId)]; + + if (options?.cursor) { + conditions.push(lt(WebhookDeliveriesSchema.id, options.cursor)); + } + if (options?.status) { + conditions.push(eq(WebhookDeliveriesSchema.status, options.status)); + } + + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(WebhookDeliveriesSchema) + .where(and(...conditions)) + .orderBy(desc(WebhookDeliveriesSchema.id)) + .limit(limit) + ); + return result ?? []; + } + + async resetConsecutiveFailures(webhookId: number): Promise { + await callWithCatch(() => + this.db.dbDrizzle.update(WebhooksSchema) + .set({ consecutiveFailures: 0 }) + .where(eq(WebhooksSchema.id, webhookId)) + ); + } + + async incrementConsecutiveFailures(webhookId: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(WebhooksSchema) + .set({ consecutiveFailures: sql`${WebhooksSchema.consecutiveFailures} + 1` }) + .where(eq(WebhooksSchema.id, webhookId)) + .returning({ consecutiveFailures: WebhooksSchema.consecutiveFailures }) + ); + return result?.[0]?.consecutiveFailures ?? 0; + } + + async deactivate(webhookId: number): Promise { + await callWithCatch(() => + this.db.dbDrizzle.update(WebhooksSchema) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(WebhooksSchema.id, webhookId)) + ); + } +} diff --git a/api/src/tv-modules/webhooks/WebhooksRoutes.ts b/api/src/tv-modules/webhooks/WebhooksRoutes.ts new file mode 100644 index 0000000..cf60826 --- /dev/null +++ b/api/src/tv-modules/webhooks/WebhooksRoutes.ts @@ -0,0 +1,30 @@ +import { Router } from 'express'; +import type { Routable } from '../../types/routable.type'; +import { IsLoggedIn } from '../auth/middlewares/is-logged-in'; +import { WebhooksController } from './WebhooksController'; + +export default class WebhooksRoutes implements Routable { + private readonly router: ReturnType; + private readonly controller: WebhooksController; + + constructor() { + this.router = Router(); + this.controller = new WebhooksController(); + this.initRoutes(); + } + + getRouter() { + return this.router; + } + + initRoutes() { + this.router.get('', [IsLoggedIn], this.controller.fetch); + this.router.post('', [IsLoggedIn], this.controller.create); + this.router.patch('', [IsLoggedIn], this.controller.update); + this.router.delete('', [IsLoggedIn], this.controller.delete); + this.router.post('/rotate-secret', [IsLoggedIn], this.controller.rotateSecret); + this.router.post('/test', [IsLoggedIn], this.controller.testDelivery); + this.router.get('/deliveries/:id', [IsLoggedIn], this.controller.fetchDeliveries); + this.router.post('/retry', [IsLoggedIn], this.controller.retryDelivery); + } +} diff --git a/api/src/tv-modules/webhooks/types.ts b/api/src/tv-modules/webhooks/types.ts new file mode 100644 index 0000000..1e6856d --- /dev/null +++ b/api/src/tv-modules/webhooks/types.ts @@ -0,0 +1,66 @@ +import { type } from 'arktype'; + +const NumberFromString = type('string|number').pipe((v) => Number(v)); + +export const WebhookArkTypeCreate = type({ + goalId: 'number', + url: 'string', + events: 'string[]', +}); + +export type WebhookArgCreate = typeof WebhookArkTypeCreate.infer; + +export const WebhookArkTypeUpdate = type({ + id: 'number', + 'url?': 'string', + 'events?': 'string[]', + 'isActive?': 'boolean', +}); + +export type WebhookArgUpdate = typeof WebhookArkTypeUpdate.infer; + +export const WebhookArkTypeDelete = type({ + id: 'number', +}); + +export type WebhookArgDelete = typeof WebhookArkTypeDelete.infer; + +export const WebhookArkTypeFetch = type({ + goalId: NumberFromString, +}); + +export type WebhookArgFetch = typeof WebhookArkTypeFetch.infer; + +export const WebhookArkTypeById = type({ + id: NumberFromString, +}); + +export type WebhookArgById = typeof WebhookArkTypeById.infer; + +const OptionalNumberFromString = type('string|number|undefined').pipe((v) => v === undefined ? undefined : Number(v)); + +export const WebhookArkTypeFetchDeliveries = type({ + id: NumberFromString, + 'cursor?': OptionalNumberFromString, + 'status?': 'string', +}); + +export type WebhookArgFetchDeliveries = typeof WebhookArkTypeFetchDeliveries.infer; + +export const WEBHOOK_EVENTS = [ + 'task.created', + 'task.updated', + 'task.deleted', + 'task.assigneesChanged', +] as const; + +export type WebhookEvent = typeof WEBHOOK_EVENTS[number]; + +export interface WebhookDeliverJobData { + deliveryId: number; + webhookId: number; + url: string; + secretEncrypted: string; + payload: object; + attempt: number; +} diff --git a/docs/2.features/6.notifications.md b/docs/2.features/6.notifications.md new file mode 100644 index 0000000..59e7bec --- /dev/null +++ b/docs/2.features/6.notifications.md @@ -0,0 +1,99 @@ +--- +title: Notifications +description: Real-time and push notifications in TaskView - deadline alerts, assignment notifications, per-user preferences, and multi-channel delivery via WebSocket and Firebase Cloud Messaging. +navigation: + icon: i-lucide-bell +--- + +TaskView notifies you when things happen in your projects. Notifications are delivered through multiple channels and can be customized per user. + +## Notification types + +| Type | When it fires | Delivery | Status | +|---|---|---|---| +| **Deadline** | When a task deadline is reached | Scheduled via background job (pgboss). If the deadline is already past when set, fires immediately. | Available | +| **Assignment** | When you are assigned to a task | Immediate | Available | +| **Mention** | When someone mentions you | Immediate | Planned | +| **Comment** | When someone comments on your task | Immediate | Planned | +| **Status change** | When a task status changes | Immediate | Planned | + +## Delivery channels + +Notifications can be sent through two channels (with email planned for the future): + +| Channel | Description | Required configuration | +|---|---|---| +| **Push** | Native push notifications on iOS/Android via Firebase Cloud Messaging | `FIREBASE_CREDENTIALS_PATH` | +| **In-app (WebSocket)** | Real-time delivery to the browser via Centrifugo | `CENTRIFUGO_API_URL`, `CENTRIFUGO_API_KEY`, `CENTRIFUGO_TOKEN_SECRET`, `CENTRIFUGO_PUBLIC_PORT` | + +Both channels are optional. If Firebase is not configured, push notifications are silently skipped. If Centrifugo is not configured, in-app real-time delivery is skipped. Notifications are always saved to the database regardless of channel availability. + +## User preferences + +Each user can control which notifications they receive and through which channels. Settings are available in **Account Settings > Notification Settings**. + +Preferences follow an **opt-out model**: everything is enabled by default. Users explicitly disable what they do not want. + +### Global and per-project settings + +Preferences support two levels: + +- **Global** applies to all projects +- **Project overrides** apply to a specific project and are merged on top of global settings + +For example, a user can enable push for all deadline notifications globally, but disable push for deadlines in a specific project. + +### Deadline intervals (planned) + +::callout{icon="i-lucide-construction" color="warning"} +Deadline intervals are defined in the preferences structure but not yet active. Currently, deadline notifications fire once at the moment of the deadline. Multiple interval support is planned for a future release. +:: + +The preferences structure supports the following intervals (minutes before the deadline): + +| Interval | Description | +|---|---| +| `0` | At the moment of the deadline | +| `15` | 15 minutes before | +| `30` | 30 minutes before | +| `60` | 1 hour before | +| `1440` | 1 day before | + +Each interval can be independently enabled or disabled. + +## How it works + +1. An event occurs (task created, deadline changed, assignees changed, etc.) +2. **NotificationDispatcher** listens to the event bus and determines the notification type, recipients, and whether to send immediately or schedule a background job +3. For deadlines, **DeadlineScheduler** creates a pgboss job that fires at the right time +4. When it is time to deliver, **NotificationService** checks the user's preferences, saves the notification to the database, and sends it through enabled channels only +5. **Providers** (FCMProvider, CentrifugoProvider) handle the actual delivery + +## Viewing notifications + +Click the bell icon in the sidebar to open the notification panel. From there you can: + +- See all your notifications with type icons and timestamps +- Click a notification to navigate to the related task +- Mark individual notifications as read +- Mark all notifications as read +- Load older notifications via pagination + +Notifications older than 1 day are automatically cleaned up by a daily background job. + +## Configuration + +See [Environment Variables](/docs/configuration/environment-variables#notifications) for the full list of notification-related variables. + +## API endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/module/notifications` | Fetch notifications (cursor pagination) | +| `PATCH` | `/module/notifications/read` | Mark a notification as read | +| `PATCH` | `/module/notifications/read-all` | Mark all notifications as read | +| `GET` | `/module/notifications/preferences` | Get user notification preferences | +| `PUT` | `/module/notifications/preferences` | Save user notification preferences | +| `GET` | `/module/notifications/connection-token` | Get WebSocket connection token | +| `POST` | `/module/notifications/device/register` | Register a device for push notifications | +| `POST` | `/module/notifications/device/unregister` | Unregister a device | diff --git a/docs/2.features/7.webhooks.md b/docs/2.features/7.webhooks.md new file mode 100644 index 0000000..61870e6 --- /dev/null +++ b/docs/2.features/7.webhooks.md @@ -0,0 +1,170 @@ +--- +title: Webhooks +description: Configure webhooks in TaskView to receive real-time HTTP notifications when tasks are created, updated, deleted, or reassigned. Includes HMAC-SHA256 signature verification, automatic retries, and delivery history. +navigation: + icon: i-lucide-webhook +--- + +Webhooks let you receive HTTP POST requests when events happen in your projects. Use them to integrate TaskView with external systems - CI/CD pipelines, Slack bots, custom dashboards, or any service that can accept HTTP requests. + +## Supported events + +| Event | When it fires | +|---|---| +| `task.created` | A new task is created in the project | +| `task.updated` | A task is updated (description, status, priority, deadline, etc.) | +| `task.deleted` | A task is deleted | +| `task.assigneesChanged` | Task assignees are added or removed | + +## Setup + +1. Open a project in TaskView +2. Right-click the project in the sidebar → **"Webhooks"** +3. Click **"Add Webhook"** +4. Enter the URL where you want to receive events +5. Select which events to subscribe to +6. Click **"Add"** +7. Copy the secret and store it securely - it will not be shown again + +## Payload format + +Every webhook delivery is an HTTP POST with `Content-Type: application/json`: + +```json +{ + "event": "task.updated", + "timestamp": "2026-03-22T12:00:00.000Z", + "task": { + "id": 123, + "goalId": 774, + "description": "Fix login bug", + "complete": false, + "statusId": 5, + "priorityId": 2, + "tags": [1, 3], + "assignedUsers": [10, 22], + "subtasks": [] + }, + "changes": { + "statusId": 5 + }, + "initiatorId": 1 +} +``` + +The `changes` field is only present on `task.updated` events and contains only the fields that changed. + +## Signature verification + +Every request includes an `X-Webhook-Signature` header with an HMAC-SHA256 signature of the request body: + +``` +X-Webhook-Signature: sha256=5d41402abc4b2a76b9719d911017c592... +``` + +Always verify the signature before processing the payload. Example in Node.js: + +```javascript +const crypto = require('crypto') + +function verifySignature(body, signature, secret) { + const expected = 'sha256=' + crypto + .createHmac('sha256', secret) + .update(body) + .digest('hex') + return signature === expected +} + +// In your HTTP handler: +const body = req.body // raw string, not parsed JSON +const signature = req.headers['x-webhook-signature'] +const isValid = verifySignature(body, signature, YOUR_SECRET) +``` + +::callout{icon="i-lucide-shield-alert" color="warning"} +Never process webhook payloads without verifying the signature. Without verification, anyone who knows your webhook URL can send fake events. +:: + +## Retries + +If your server responds with a non-2xx status code or doesn't respond within 10 seconds, TaskView retries the delivery: + +| Attempt | Delay | +|---|---| +| 1st retry | ~10 seconds | +| 2nd retry | ~20 seconds | + +After 3 total attempts (1 original + 2 retries), the delivery is marked as **failed**. + +## Auto-deactivation + +If a webhook accumulates **10 consecutive failed deliveries** (after all retries are exhausted), it is automatically deactivated. A single successful delivery resets the failure counter. + +To reactivate a webhook, toggle it back on from the webhooks page. The failure counter is not reset automatically - the next successful delivery will reset it. + +## Delivery history + +The webhooks page shows delivery history for each webhook: +- **Event** - which event was delivered +- **Status** - success, failed, or pending +- **HTTP code** - response status code from your server +- **Attempts** - how many attempts were made +- **Payload** - click to view the full JSON payload + +Failed deliveries can be retried manually from the delivery history. + +## Managing webhooks + +From the webhooks page you can: +- **Toggle** webhooks on/off +- **Edit** the URL and subscribed events +- **Test** - sends a test payload to verify connectivity +- **Rotate secret** - generates a new secret (the old one stops working immediately) +- **Delete** - removes the webhook and all delivery history +- **View deliveries** - see delivery history with status filter + +## Secret rotation + +If your secret is compromised, rotate it: + +1. Click the key icon on the webhook +2. Confirm that you want to rotate +3. Copy the new secret +4. Update the secret in your receiving application + +The old secret stops working immediately. Any in-flight deliveries signed with the old secret will fail signature verification on your end. + +## Testing locally + +You can use a simple Node.js script to test webhook deliveries: + +```javascript +const http = require('http') +const crypto = require('crypto') + +const PORT = 4545 +const SECRET = 'your-secret-here' + +const server = http.createServer((req, res) => { + const chunks = [] + req.on('data', (chunk) => chunks.push(chunk)) + req.on('end', () => { + const body = Buffer.concat(chunks).toString() + const signature = req.headers['x-webhook-signature'] || '' + const expected = 'sha256=' + crypto + .createHmac('sha256', SECRET) + .update(body) + .digest('hex') + + console.log(signature === expected ? 'Valid' : 'INVALID') + console.log(JSON.stringify(JSON.parse(body), null, 2)) + + res.writeHead(200) + res.end('OK') + }) +}) + +server.listen(PORT, () => console.log(`Listening on :${PORT}`)) +``` + +Run with `node webhook-receiver.js` and set the webhook URL to `http://localhost:4545`. diff --git a/docs/4.configuration/1.environment-variables.md b/docs/4.configuration/1.environment-variables.md index 50ab739..c5f7bc5 100644 --- a/docs/4.configuration/1.environment-variables.md +++ b/docs/4.configuration/1.environment-variables.md @@ -94,6 +94,33 @@ For connecting GitLab repositories. | `GITLAB_BASE_URL` | No | `https://gitlab.com` | Override for self-hosted GitLab | | `GITLAB_API_URL` | No | `https://gitlab.com/api/v4` | Override for self-hosted GitLab API | +## Notifications + +Optional configuration for real-time and push notification delivery. + +### Centrifugo (real-time WebSocket notifications) + +Required for in-app real-time notification delivery. Without Centrifugo, notifications are still saved to the database but will not appear instantly in the browser. Users will see them on the next page load. + +| Variable | Required | Default | Description | +|---|---|---|---| +| `CENTRIFUGO_API_URL` | No | - | Internal Centrifugo API URL (e.g. `http://centrifugo:8000`) | +| `CENTRIFUGO_API_KEY` | No | - | Centrifugo API key for server-to-server communication | +| `CENTRIFUGO_TOKEN_SECRET` | No | - | Secret for generating client connection tokens (HMAC) | +| `CENTRIFUGO_PUBLIC_PORT` | No | - | Public port that browser clients use to connect to Centrifugo WebSocket | + +### Firebase Cloud Messaging (mobile push notifications) + +Required for native push notifications on iOS and Android. Without Firebase, push notifications are silently skipped. + +| Variable | Required | Default | Description | +|---|---|---|---| +| `FIREBASE_CREDENTIALS_PATH` | No | - | Path to Firebase service account JSON file (e.g. `./firebase-credentials.json`) | + +::callout{icon="i-lucide-info" color="info"} +Both Centrifugo and Firebase are optional. If not configured, their respective channels are skipped. Notifications are always persisted to the database. +:: + ## Full example Here's a complete `.env.taskview` file for a production deployment: @@ -145,4 +172,11 @@ GITLAB_INTEGRATION_CLIENT_SECRET= GITLAB_INTEGRATION_CALLBACK_URL=https://api.taskview.tech/module/integrations/oauth/github/callback ENCRYPTION_KEY= + +# Notifications (optional) +# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json +# CENTRIFUGO_API_URL=http://centrifugo:8000 +# CENTRIFUGO_API_KEY=your_centrifugo_api_key +# CENTRIFUGO_TOKEN_SECRET=your_centrifugo_token_secret +# CENTRIFUGO_PUBLIC_PORT=8000 ``` diff --git a/package.json b/package.json index 58ab4f4..540603f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,11 @@ "@types/node": "^18.19.119", "typescript": "^5.7.3" }, + "pnpm": { + "overrides": { + "pg": "^8.20.0" + } + }, "engines": { "node": ">=18.0.0", "pnpm": ">=8.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 448d952..85e6bb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + pg: ^8.20.0 + importers: .: @@ -22,7 +25,7 @@ importers: version: 2.1.20 drizzle-arktype: specifier: 0.1.3 - version: 0.1.3(arktype@2.1.20)(drizzle-orm@0.44.7(@types/pg@8.16.0)(pg@8.20.0)) + version: 0.1.3(arktype@2.1.20)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.20.0)) passport: specifier: ^0.7.0 version: 0.7.0 @@ -80,16 +83,19 @@ importers: version: 16.6.1 drizzle-arktype: specifier: ^0.1.3 - version: 0.1.3(arktype@2.1.20)(drizzle-orm@0.44.7(@types/pg@8.16.0)(pg@8.17.1)) + version: 0.1.3(arktype@2.1.20)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.20.0)) drizzle-orm: specifier: ^0.44.4 - version: 0.44.7(@types/pg@8.16.0)(pg@8.17.1) + version: 0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.20.0) emailjs: specifier: ^4.0.3 version: 4.0.3(typescript@5.9.3) express: specifier: 4.21.0 version: 4.21.0 + firebase-admin: + specifier: ^12.7.0 + version: 12.7.0 helmet: specifier: ^7.1.0 version: 7.2.0 @@ -109,8 +115,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 pg: - specifier: ^8.16.3 - version: 8.17.1 + specifier: ^8.20.0 + version: 8.20.0 pg-boss: specifier: ^12.14.0 version: 12.14.0 @@ -181,6 +187,9 @@ importers: nock: specifier: ^13.5.5 version: 13.5.6 + tsx: + specifier: ^4.21.0 + version: 4.21.0 vite: specifier: ^5.4.9 version: 5.4.21(@types/node@22.19.7)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0) @@ -195,7 +204,7 @@ importers: dependencies: vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@25.3.0)(rollup@4.55.2)(typescript@5.8.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2)) + version: 4.5.4(@types/node@25.3.0)(rollup@4.55.2)(typescript@5.8.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) devDependencies: '@testcontainers/postgresql': specifier: ^11.7.1 @@ -210,8 +219,8 @@ importers: specifier: ^1.3.0 version: 1.3.6 pg: - specifier: ^8.11.3 - version: 8.17.1 + specifier: ^8.20.0 + version: 8.20.0 testcontainers: specifier: ^11.7.1 version: 11.11.0 @@ -220,10 +229,10 @@ importers: version: 5.8.3 vite: specifier: ^7.0.4 - version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@25.3.0)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + version: 3.2.4(@types/node@25.3.0)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) taskview-packages/taskview-db-schemas: dependencies: @@ -232,23 +241,26 @@ importers: version: 2.1.20 drizzle-arktype: specifier: ^0.1.3 - version: 0.1.3(arktype@2.1.20)(drizzle-orm@0.44.7(@types/pg@8.16.0)(pg@8.20.0)) - drizzle-orm: - specifier: ^0.44.4 - version: 0.44.7(@types/pg@8.16.0)(pg@8.20.0) + version: 0.1.3(arktype@2.1.20)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.20.0)) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@25.3.0)(rollup@4.55.2)(typescript@5.8.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2)) + version: 4.5.4(@types/node@25.3.0)(rollup@4.55.2)(typescript@5.8.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) devDependencies: + drizzle-orm: + specifier: ^0.44.4 + version: 0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.20.0) typescript: specifier: ~5.8.3 version: 5.8.3 vite: specifier: ^7.0.4 - version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: + '@capacitor-firebase/messaging': + specifier: ^8.1.0 + version: 8.1.0(@capacitor/core@8.1.0)(firebase@12.10.0) '@capacitor/android': specifier: ^8.1.0 version: 8.1.0(@capacitor/core@8.1.0) @@ -270,6 +282,9 @@ importers: '@capacitor/preferences': specifier: 8.0.1 version: 8.0.1(@capacitor/core@8.1.0) + '@capacitor/push-notifications': + specifier: ^8.0.2 + version: 8.0.2(@capacitor/core@8.1.0) '@capacitor/splash-screen': specifier: ^8.0.1 version: 8.0.1(@capacitor/core@8.1.0) @@ -296,7 +311,7 @@ importers: version: 3.11.0 '@nuxt/ui': specifier: ^4.4.0 - version: 4.4.0(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(axios@1.13.5)(embla-carousel@8.6.0)(sortablejs@1.14.0)(tailwindcss@4.2.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.29)(zod@4.3.6) + version: 4.4.0(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(axios@1.13.5)(embla-carousel@8.6.0)(sortablejs@1.14.0)(tailwindcss@4.2.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.29)(zod@4.3.6) '@tanstack/table-core': specifier: ^8.21.3 version: 8.21.3 @@ -363,6 +378,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + firebase: + specifier: ^12.10.0 + version: 12.10.0 pinia: specifier: ^2.3.1 version: 2.3.1(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)) @@ -408,7 +426,7 @@ importers: version: 22.19.7 '@vitejs/plugin-vue': specifier: ^6.0.3 - version: 6.0.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) eslint: specifier: ^9.39.2 version: 9.39.3(jiti@2.6.1) @@ -426,7 +444,7 @@ importers: version: 8.53.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + version: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vue-tsc: specifier: ^3.2.2 version: 3.2.5(typescript@5.9.3) @@ -997,6 +1015,15 @@ packages: cpu: [x64] os: [win32] + '@capacitor-firebase/messaging@8.1.0': + resolution: {integrity: sha512-SjF14b29/fXlemfRp8tusmc9NGbJjKk8mJ0/mqypoV0Ib+4a5SqJbYK+56fTOGo6fG97r/rIadW4LgXUmyMHBg==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + firebase: ^12.6.0 + peerDependenciesMeta: + firebase: + optional: true + '@capacitor/android@8.1.0': resolution: {integrity: sha512-z0acTPxj5DCy/U2FU7w+GA93oC+wdyKnsOcRg5rutDmSYa8Do1tzYqApKgf+hnuTNPbtrCTHp0Zy1cLiK/4MEw==} peerDependencies: @@ -1045,6 +1072,11 @@ packages: peerDependencies: '@capacitor/core': '>=8.0.0' + '@capacitor/push-notifications@8.0.2': + resolution: {integrity: sha512-mej8Eh4EU4JXbaeyMX2l+4NNK6IdY5iRsEXpBOTHLD29fuCACBP7kOJTiJv6piw134zdlbFH0rR+sDKHIiFOFg==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/splash-screen@8.0.1': resolution: {integrity: sha512-c/ew/Z3eA7z8l06WoRAtzVF16VwYYrExmHmfGq1Cg675pVzaC/yuucB8/1xG1vhEfnW4fZ1KhSf/kzR1RiVYgg==} peerDependencies: @@ -1588,6 +1620,246 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@firebase/ai@2.9.0': + resolution: {integrity: sha512-NPvBBuvdGo9x3esnABAucFYmqbBmXvyTMimBq2PCuLZbdANZoHzGlx7vfzbwNDaEtCBq4RGGNMliLIv6bZ+PtA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + '@firebase/app-types': 0.x + + '@firebase/analytics-compat@0.2.26': + resolution: {integrity: sha512-0j2ruLOoVSwwcXAF53AMoniJKnkwiTjGVfic5LDzqiRkR13vb5j6TXMeix787zbLeQtN/m1883Yv1TxI0gItbA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/analytics-types@0.8.3': + resolution: {integrity: sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==} + + '@firebase/analytics@0.10.20': + resolution: {integrity: sha512-adGTNVUWH5q66tI/OQuKLSN6mamPpfYhj0radlH2xt+3eL6NFPtXoOs+ulvs+UsmK27vNFx5FjRDfWk+TyduHg==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/app-check-compat@0.4.1': + resolution: {integrity: sha512-yjSvSl5B1u4CirnxhzirN1uiTRCRfx+/qtfbyeyI+8Cx8Cw1RWAIO/OqytPSVwLYbJJ1vEC3EHfxazRaMoWKaA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/app-check-interop-types@0.3.2': + resolution: {integrity: sha512-LMs47Vinv2HBMZi49C09dJxp0QT5LwDzFaVGf/+ITHe3BlIhUiLNttkATSXplc89A2lAaeTqjgqVkiRfUGyQiQ==} + + '@firebase/app-check-interop-types@0.3.3': + resolution: {integrity: sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==} + + '@firebase/app-check-types@0.5.3': + resolution: {integrity: sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==} + + '@firebase/app-check@0.11.1': + resolution: {integrity: sha512-gmKfwQ2k8aUQlOyRshc+fOQLq0OwUmibIZvpuY1RDNu2ho0aTMlwxOuEiJeYOs7AxzhSx7gnXPFNsXCFbnvXUQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/app-compat@0.5.9': + resolution: {integrity: sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==} + engines: {node: '>=20.0.0'} + + '@firebase/app-types@0.9.2': + resolution: {integrity: sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==} + + '@firebase/app-types@0.9.3': + resolution: {integrity: sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==} + + '@firebase/app@0.14.9': + resolution: {integrity: sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==} + engines: {node: '>=20.0.0'} + + '@firebase/auth-compat@0.6.3': + resolution: {integrity: sha512-nHOkupcYuGVxI1AJJ/OBhLPaRokbP14Gq4nkkoVvf1yvuREEWqdnrYB/CdsSnPxHMAnn5wJIKngxBF9jNX7s/Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/auth-interop-types@0.2.3': + resolution: {integrity: sha512-Fc9wuJGgxoxQeavybiuwgyi+0rssr76b+nHpj+eGhXFYAdudMWyfBHvFL/I5fEHniUM/UQdFzi9VXJK2iZF7FQ==} + + '@firebase/auth-interop-types@0.2.4': + resolution: {integrity: sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==} + + '@firebase/auth-types@0.13.0': + resolution: {integrity: sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/auth@1.12.1': + resolution: {integrity: sha512-nXKj7d5bMBlnq6XpcQQpmnSVwEeHBkoVbY/+Wk0P1ebLSICoH4XPtvKOFlXKfIHmcS84mLQ99fk3njlDGKSDtw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + '@react-native-async-storage/async-storage': ^2.2.0 + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + + '@firebase/component@0.6.9': + resolution: {integrity: sha512-gm8EUEJE/fEac86AvHn8Z/QW8BvR56TBw3hMW0O838J/1mThYQXAIQBgUv75EqlCZfdawpWLrKt1uXvp9ciK3Q==} + + '@firebase/component@0.7.1': + resolution: {integrity: sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==} + engines: {node: '>=20.0.0'} + + '@firebase/data-connect@0.4.0': + resolution: {integrity: sha512-vLXM6WHNIR3VtEeYNUb/5GTsUOyl3Of4iWNZHBe1i9f88sYFnxybJNWVBjvJ7flhCyF8UdxGpzWcUnv6F5vGfg==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/database-compat@1.0.8': + resolution: {integrity: sha512-OpeWZoPE3sGIRPBKYnW9wLad25RaWbGyk7fFQe4xnJQKRzlynWeFBSRRAoLE2Old01WXwskUiucNqUUVlFsceg==} + + '@firebase/database-compat@2.1.1': + resolution: {integrity: sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==} + engines: {node: '>=20.0.0'} + + '@firebase/database-types@1.0.17': + resolution: {integrity: sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==} + + '@firebase/database-types@1.0.5': + resolution: {integrity: sha512-fTlqCNwFYyq/C6W7AJ5OCuq5CeZuBEsEwptnVxlNPkWCo5cTTyukzAHRSO/jaQcItz33FfYrrFk1SJofcu2AaQ==} + + '@firebase/database@1.0.8': + resolution: {integrity: sha512-dzXALZeBI1U5TXt6619cv0+tgEhJiwlUtQ55WNZY7vGAjv7Q1QioV969iYwt1AQQ0ovHnEW0YW9TiBfefLvErg==} + + '@firebase/database@1.1.1': + resolution: {integrity: sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==} + engines: {node: '>=20.0.0'} + + '@firebase/firestore-compat@0.4.6': + resolution: {integrity: sha512-NgVyR4hHHN2FvSNQOtbgBOuVsEdD/in30d9FKbEvvITiAChrBN2nBstmhfjI4EOTnHaP8zigwvkNYFI9yKGAkQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/firestore-types@3.0.3': + resolution: {integrity: sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/firestore@4.12.0': + resolution: {integrity: sha512-PM47OyiiAAoAMB8kkq4Je14mTciaRoAPDd3ng3Ckqz9i2TX9D9LfxIRcNzP/OxzNV4uBKRq6lXoOggkJBQR3Gw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/functions-compat@0.4.2': + resolution: {integrity: sha512-YNxgnezvZDkqxqXa6cT7/oTeD4WXbxgIP7qZp4LFnathQv5o2omM6EoIhXiT9Ie5AoQDcIhG9Y3/dj+DFJGaGQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/functions-types@0.6.3': + resolution: {integrity: sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==} + + '@firebase/functions@0.13.2': + resolution: {integrity: sha512-tHduUD+DeokM3NB1QbHCvEMoL16e8Z8JSkmuVA4ROoJKPxHn8ibnecHPO2e3nVCJR1D9OjuKvxz4gksfq92/ZQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/installations-compat@0.2.20': + resolution: {integrity: sha512-9C9pL/DIEGucmoPj8PlZTnztbX3nhNj5RTYVpUM7wQq/UlHywaYv99969JU/WHLvi9ptzIogXYS9d1eZ6XFe9g==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/installations-types@0.5.3': + resolution: {integrity: sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==} + peerDependencies: + '@firebase/app-types': 0.x + + '@firebase/installations@0.6.20': + resolution: {integrity: sha512-LOzvR7XHPbhS0YB5ANXhqXB5qZlntPpwU/4KFwhSNpXNsGk/sBQ9g5hepi0y0/MfenJLe2v7t644iGOOElQaHQ==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/logger@0.4.2': + resolution: {integrity: sha512-Q1VuA5M1Gjqrwom6I6NUU4lQXdo9IAQieXlujeHZWvRt1b7qQ0KwBaNAjgxG27jgF9/mUwsNmO8ptBCGVYhB0A==} + + '@firebase/logger@0.5.0': + resolution: {integrity: sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==} + engines: {node: '>=20.0.0'} + + '@firebase/messaging-compat@0.2.24': + resolution: {integrity: sha512-wXH8FrKbJvFuFe6v98TBhAtvgknxKIZtGM/wCVsfpOGmaAE80bD8tBxztl+uochjnFb9plihkd6mC4y7sZXSpA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/messaging-interop-types@0.2.3': + resolution: {integrity: sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==} + + '@firebase/messaging@0.12.24': + resolution: {integrity: sha512-UtKoubegAhHyehcB7iQjvQ8OVITThPbbWk3g2/2ze42PrQr6oe6OmCElYQkBrE5RDCeMTNucXejbdulrQ2XwVg==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/performance-compat@0.2.23': + resolution: {integrity: sha512-c7qOAGBUAOpIuUlHu1axWcrCVtIYKPMhH0lMnoCDWnPwn1HcPuPUBVTWETbC7UWw71RMJF8DpirfWXzMWJQfgA==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/performance-types@0.2.3': + resolution: {integrity: sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==} + + '@firebase/performance@0.7.10': + resolution: {integrity: sha512-8nRFld+Ntzp5cLKzZuG9g+kBaSn8Ks9dmn87UQGNFDygbmR6ebd8WawauEXiJjMj1n70ypkvAOdE+lzeyfXtGA==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/remote-config-compat@0.2.22': + resolution: {integrity: sha512-uW/eNKKtRBot2gnCC5mnoy5Voo2wMzZuQ7dwqqGHU176fO9zFgMwKiRzk+aaC99NLrFk1KOmr0ZVheD+zdJmjQ==} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/remote-config-types@0.5.0': + resolution: {integrity: sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==} + + '@firebase/remote-config@0.8.1': + resolution: {integrity: sha512-L86TReBnPiiJOWd7k9iaiE9f7rHtMpjAoYN0fH2ey2ZRzsOChHV0s5sYf1+IIUYzplzsE46pjlmAUNkRRKwHSQ==} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/storage-compat@0.4.1': + resolution: {integrity: sha512-bgl3FHHfXAmBgzIK/Fps6Xyv2HiAQlSTov07CBL+RGGhrC5YIk4lruS8JVIC+UkujRdYvnf8cpQFGn2RCilJ/A==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app-compat': 0.x + + '@firebase/storage-types@0.8.3': + resolution: {integrity: sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==} + peerDependencies: + '@firebase/app-types': 0.x + '@firebase/util': 1.x + + '@firebase/storage@0.14.1': + resolution: {integrity: sha512-uIpYgBBsv1vIET+5xV20XT7wwqV+H4GFp6PBzfmLUcEgguS4SWNFof56Z3uOC2lNDh0KDda1UflYq2VwD9Nefw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@firebase/app': 0.x + + '@firebase/util@1.10.0': + resolution: {integrity: sha512-xKtx4A668icQqoANRxyDLBLz51TAbDP9KRfpbKGxiCAW346d0BeJe5vN6/hKxxmWwnZ0mautyv39JxviwwQMOQ==} + + '@firebase/util@1.14.0': + resolution: {integrity: sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==} + engines: {node: '>=20.0.0'} + + '@firebase/webchannel-wrapper@1.0.5': + resolution: {integrity: sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==} + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1606,10 +1878,34 @@ packages: '@floating-ui/vue@1.1.10': resolution: {integrity: sha512-vdf8f6rHnFPPLRsmL4p12wYl+Ux4mOJOkjzKEMYVnwdf7UFdvBtHlLvQyx8iKG5vhPRbDRgZxdtpmyigDPjzYg==} + '@google-cloud/firestore@7.11.6': + resolution: {integrity: sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==} + engines: {node: '>=14.0.0'} + + '@google-cloud/paginator@5.0.2': + resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} + engines: {node: '>=14.0.0'} + + '@google-cloud/projectify@4.0.0': + resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} + engines: {node: '>=14.0.0'} + + '@google-cloud/promisify@4.0.0': + resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} + engines: {node: '>=14'} + + '@google-cloud/storage@7.19.0': + resolution: {integrity: sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==} + engines: {node: '>=14'} + '@grpc/grpc-js@1.14.3': resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} + '@grpc/grpc-js@1.9.15': + resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==} + engines: {node: ^8.13.0 || >=10.10.0} + '@grpc/proto-loader@0.7.15': resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} engines: {node: '>=6'} @@ -1888,6 +2184,10 @@ packages: '@nuxtjs/color-mode@3.5.2': resolution: {integrity: sha512-cC6RfgZh3guHBMLLjrBB2Uti5eUoGM9KyauOaYS9ETmxNWBMTvpgjvSiSJp1OFljIXPIqVTJ3xtJpSNZiO3ZaA==} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@oven/bun-darwin-aarch64@1.3.6': resolution: {integrity: sha512-27rypIapNkYboOSylkf1tD9UW9Ado2I+P1NBL46Qz29KmOjTL6WuJ7mHDC5O66CYxlOkF5r93NPDAC3lFHYBXw==} cpu: [arm64] @@ -2644,6 +2944,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2805,6 +3108,9 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/mapbox__point-geometry@0.1.4': resolution: {integrity: sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==} @@ -2871,6 +3177,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -2922,6 +3231,9 @@ packages: '@types/topojson@3.2.6': resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} @@ -3319,6 +3631,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} @@ -3416,6 +3732,10 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -3433,6 +3753,9 @@ packages: async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -3551,6 +3874,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -4350,7 +4676,7 @@ packages: knex: '*' kysely: '*' mysql2: '>=2' - pg: '>=8' + pg: ^8.20.0 postgres: '>=3' prisma: '*' sql.js: '>=1' @@ -4419,6 +4745,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + earcut@2.2.4: resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} @@ -4715,6 +5044,13 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + farmhash-modern@1.1.0: + resolution: {integrity: sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==} + engines: {node: '>=18.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4731,9 +5067,20 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.5.6: + resolution: {integrity: sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -4773,6 +5120,13 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + firebase-admin@12.7.0: + resolution: {integrity: sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==} + engines: {node: '>=14'} + + firebase@12.10.0: + resolution: {integrity: sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -4813,6 +5167,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@2.5.5: + resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} + engines: {node: '>= 0.12'} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -4882,10 +5240,21 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + fuse.js@7.1.0: resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} engines: {node: '>=10'} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -4930,6 +5299,9 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -4995,6 +5367,18 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-gax@4.6.1: + resolution: {integrity: sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -5006,6 +5390,10 @@ packages: resolution: {integrity: sha512-is3hDn9zb8XXnjbEeAEIqxTpLHUiGBqjegLmXPuyMBfKAggpadWFku4/AP8iYAGBX6qR9/5UIUIp47V0XI3aMw==} hasBin: true + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + h3@1.15.5: resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==} @@ -5069,10 +5457,16 @@ packages: resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} engines: {node: '>=12'} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -5081,6 +5475,10 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -5093,6 +5491,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + ieee754@1.1.13: resolution: {integrity: sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==} @@ -5273,6 +5674,9 @@ packages: resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} engines: {node: '>= 0.6.0'} + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5297,6 +5701,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -5340,6 +5747,10 @@ packages: jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwks-rsa@3.2.2: + resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} + engines: {node: '>=14'} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -5457,6 +5868,9 @@ packages: resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -5492,6 +5906,9 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -5548,6 +5965,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru-memoizer@2.3.0: + resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} @@ -5637,6 +6057,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -5808,6 +6233,10 @@ packages: encoding: optional: true + node-forge@1.3.3: + resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} + engines: {node: '>= 6.13.0'} + node-html-parser@5.4.2: resolution: {integrity: sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw==} @@ -5854,6 +6283,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -5991,6 +6424,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.1.3: + resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -6056,9 +6493,6 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.10.0: - resolution: {integrity: sha512-ur/eoPKzDx2IjPaYyXS6Y8NSblxM7X64deV2ObV57vhjsWiwLvUD6meukAzogiOsu60GO8m/3Cb6FdJsWNjwXg==} - pg-connection-string@2.12.0: resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} @@ -6066,15 +6500,10 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-pool@3.11.0: - resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} - peerDependencies: - pg: '>=8.0' - pg-pool@3.13.0: resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} peerDependencies: - pg: '>=8.0' + pg: ^8.20.0 pg-protocol@1.11.0: resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} @@ -6086,15 +6515,6 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.17.1: - resolution: {integrity: sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==} - engines: {node: '>= 16.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - pg@8.20.0: resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} engines: {node: '>= 16.0.0'} @@ -6293,6 +6713,10 @@ packages: prosemirror-view@1.41.5: resolution: {integrity: sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==} + proto3-json-serializer@2.0.2: + resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} + engines: {node: '>=14.0.0'} + protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -6487,6 +6911,9 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-protobuf-schema@2.1.0: resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} @@ -6498,10 +6925,18 @@ packages: restructure@3.0.2: resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retry-request@7.0.2: + resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} + engines: {node: '>=14'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6761,6 +7196,12 @@ packages: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + streamx@2.23.0: resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} @@ -6816,6 +7257,12 @@ packages: striptags@3.2.0: resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==} + strnum@2.2.0: + resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==} + + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -6890,6 +7337,10 @@ packages: resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} engines: {node: '>=18'} + teeny-request@9.0.0: + resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} + engines: {node: '>=14'} + temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -7037,6 +7488,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -7326,6 +7782,10 @@ packages: resolution: {integrity: sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==} hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -7557,6 +8017,9 @@ packages: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -7567,6 +8030,14 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + websocket-driver@0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} @@ -8472,6 +8943,12 @@ snapshots: '@biomejs/cli-win32-x64@2.3.11': optional: true + '@capacitor-firebase/messaging@8.1.0(@capacitor/core@8.1.0)(firebase@12.10.0)': + dependencies: + '@capacitor/core': 8.1.0 + optionalDependencies: + firebase: 12.10.0 + '@capacitor/android@8.1.0(@capacitor/core@8.1.0)': dependencies: '@capacitor/core': 8.1.0 @@ -8569,6 +9046,10 @@ snapshots: dependencies: '@capacitor/core': 8.1.0 + '@capacitor/push-notifications@8.0.2(@capacitor/core@8.1.0)': + dependencies: + '@capacitor/core': 8.1.0 + '@capacitor/splash-screen@8.0.1(@capacitor/core@8.1.0)': dependencies: '@capacitor/core': 8.1.0 @@ -8916,6 +9397,369 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/busboy@3.2.0': {} + + '@firebase/ai@2.9.0(@firebase/app-types@0.9.3)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/app-check-interop-types': 0.3.3 + '@firebase/app-types': 0.9.3 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/analytics-compat@0.2.26(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/analytics': 0.10.20(@firebase/app@0.14.9) + '@firebase/analytics-types': 0.8.3 + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/analytics-types@0.8.3': {} + + '@firebase/analytics@0.10.20(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/app-check-compat@0.4.1(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-check': 0.11.1(@firebase/app@0.14.9) + '@firebase/app-check-types': 0.5.3 + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/app-check-interop-types@0.3.2': {} + + '@firebase/app-check-interop-types@0.3.3': {} + + '@firebase/app-check-types@0.5.3': {} + + '@firebase/app-check@0.11.1(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/app-compat@0.5.9': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/app-types@0.9.2': {} + + '@firebase/app-types@0.9.3': {} + + '@firebase/app@0.14.9': + dependencies: + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/auth-compat@0.6.3(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/auth': 1.12.1(@firebase/app@0.14.9) + '@firebase/auth-types': 0.13.0(@firebase/app-types@0.9.3)(@firebase/util@1.14.0) + '@firebase/component': 0.7.1 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + - '@react-native-async-storage/async-storage' + + '@firebase/auth-interop-types@0.2.3': {} + + '@firebase/auth-interop-types@0.2.4': {} + + '@firebase/auth-types@0.13.0(@firebase/app-types@0.9.3)(@firebase/util@1.14.0)': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.14.0 + + '@firebase/auth@1.12.1(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/component@0.6.9': + dependencies: + '@firebase/util': 1.10.0 + tslib: 2.8.1 + + '@firebase/component@0.7.1': + dependencies: + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/data-connect@0.4.0(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/auth-interop-types': 0.2.4 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/database-compat@1.0.8': + dependencies: + '@firebase/component': 0.6.9 + '@firebase/database': 1.0.8 + '@firebase/database-types': 1.0.5 + '@firebase/logger': 0.4.2 + '@firebase/util': 1.10.0 + tslib: 2.8.1 + + '@firebase/database-compat@2.1.1': + dependencies: + '@firebase/component': 0.7.1 + '@firebase/database': 1.1.1 + '@firebase/database-types': 1.0.17 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/database-types@1.0.17': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.14.0 + + '@firebase/database-types@1.0.5': + dependencies: + '@firebase/app-types': 0.9.2 + '@firebase/util': 1.10.0 + + '@firebase/database@1.0.8': + dependencies: + '@firebase/app-check-interop-types': 0.3.2 + '@firebase/auth-interop-types': 0.2.3 + '@firebase/component': 0.6.9 + '@firebase/logger': 0.4.2 + '@firebase/util': 1.10.0 + faye-websocket: 0.11.4 + tslib: 2.8.1 + + '@firebase/database@1.1.1': + dependencies: + '@firebase/app-check-interop-types': 0.3.3 + '@firebase/auth-interop-types': 0.2.4 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + faye-websocket: 0.11.4 + tslib: 2.8.1 + + '@firebase/firestore-compat@0.4.6(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/firestore': 4.12.0(@firebase/app@0.14.9) + '@firebase/firestore-types': 3.0.3(@firebase/app-types@0.9.3)(@firebase/util@1.14.0) + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/firestore-types@3.0.3(@firebase/app-types@0.9.3)(@firebase/util@1.14.0)': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.14.0 + + '@firebase/firestore@4.12.0(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + '@firebase/webchannel-wrapper': 1.0.5 + '@grpc/grpc-js': 1.9.15 + '@grpc/proto-loader': 0.7.15 + tslib: 2.8.1 + + '@firebase/functions-compat@0.4.2(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/functions': 0.13.2(@firebase/app@0.14.9) + '@firebase/functions-types': 0.6.3 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/functions-types@0.6.3': {} + + '@firebase/functions@0.13.2(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/app-check-interop-types': 0.3.3 + '@firebase/auth-interop-types': 0.2.4 + '@firebase/component': 0.7.1 + '@firebase/messaging-interop-types': 0.2.3 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/installations-compat@0.2.20(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/installations-types': 0.5.3(@firebase/app-types@0.9.3) + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/installations-types@0.5.3(@firebase/app-types@0.9.3)': + dependencies: + '@firebase/app-types': 0.9.3 + + '@firebase/installations@0.6.20(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/util': 1.14.0 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/logger@0.4.2': + dependencies: + tslib: 2.8.1 + + '@firebase/logger@0.5.0': + dependencies: + tslib: 2.8.1 + + '@firebase/messaging-compat@0.2.24(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/messaging': 0.12.24(@firebase/app@0.14.9) + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/messaging-interop-types@0.2.3': {} + + '@firebase/messaging@0.12.24(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/messaging-interop-types': 0.2.3 + '@firebase/util': 1.14.0 + idb: 7.1.1 + tslib: 2.8.1 + + '@firebase/performance-compat@0.2.23(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/performance': 0.7.10(@firebase/app@0.14.9) + '@firebase/performance-types': 0.2.3 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/performance-types@0.2.3': {} + + '@firebase/performance@0.7.10(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + web-vitals: 4.2.4 + + '@firebase/remote-config-compat@0.2.22(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/logger': 0.5.0 + '@firebase/remote-config': 0.8.1(@firebase/app@0.14.9) + '@firebase/remote-config-types': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + + '@firebase/remote-config-types@0.5.0': {} + + '@firebase/remote-config@0.8.1(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/logger': 0.5.0 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/storage-compat@0.4.1(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9)': + dependencies: + '@firebase/app-compat': 0.5.9 + '@firebase/component': 0.7.1 + '@firebase/storage': 0.14.1(@firebase/app@0.14.9) + '@firebase/storage-types': 0.8.3(@firebase/app-types@0.9.3)(@firebase/util@1.14.0) + '@firebase/util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@firebase/app' + - '@firebase/app-types' + + '@firebase/storage-types@0.8.3(@firebase/app-types@0.9.3)(@firebase/util@1.14.0)': + dependencies: + '@firebase/app-types': 0.9.3 + '@firebase/util': 1.14.0 + + '@firebase/storage@0.14.1(@firebase/app@0.14.9)': + dependencies: + '@firebase/app': 0.14.9 + '@firebase/component': 0.7.1 + '@firebase/util': 1.14.0 + tslib: 2.8.1 + + '@firebase/util@1.10.0': + dependencies: + tslib: 2.8.1 + + '@firebase/util@1.14.0': + dependencies: + tslib: 2.8.1 + + '@firebase/webchannel-wrapper@1.0.5': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -8945,11 +9789,62 @@ snapshots: - '@vue/composition-api' - vue + '@google-cloud/firestore@7.11.6': + dependencies: + '@opentelemetry/api': 1.9.0 + fast-deep-equal: 3.1.3 + functional-red-black-tree: 1.0.1 + google-gax: 4.6.1 + protobufjs: 7.5.4 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@google-cloud/paginator@5.0.2': + dependencies: + arrify: 2.0.1 + extend: 3.0.2 + optional: true + + '@google-cloud/projectify@4.0.0': + optional: true + + '@google-cloud/promisify@4.0.0': + optional: true + + '@google-cloud/storage@7.19.0': + dependencies: + '@google-cloud/paginator': 5.0.2 + '@google-cloud/projectify': 4.0.0 + '@google-cloud/promisify': 4.0.0 + abort-controller: 3.0.0 + async-retry: 1.3.3 + duplexify: 4.1.3 + fast-xml-parser: 5.5.6 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + html-entities: 2.6.0 + mime: 3.0.0 + p-limit: 3.1.0 + retry-request: 7.0.2 + teeny-request: 9.0.0 + uuid: 8.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + '@grpc/grpc-js@1.14.3': dependencies: '@grpc/proto-loader': 0.8.0 '@js-sdsl/ordered-map': 4.4.2 + '@grpc/grpc-js@1.9.15': + dependencies: + '@grpc/proto-loader': 0.7.15 + '@types/node': 22.19.7 + '@grpc/proto-loader@0.7.15': dependencies: lodash.camelcase: 4.3.0 @@ -9270,24 +10165,24 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nuxt/devtools-kit@3.2.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2))': + '@nuxt/devtools-kit@3.2.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@nuxt/kit': 4.3.1 execa: 8.0.1 - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - magicast - '@nuxt/fonts@0.12.1(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2))': + '@nuxt/fonts@0.12.1(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@nuxt/devtools-kit': 3.2.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2)) + '@nuxt/devtools-kit': 3.2.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@nuxt/kit': 4.3.1 consola: 3.4.2 css-tree: 3.1.0 defu: 6.1.4 esbuild: 0.25.12 fontaine: 0.7.0 - fontless: 0.1.0(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2)) + fontless: 0.1.0(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) h3: 1.15.5 jiti: 2.6.1 magic-regexp: 0.10.0 @@ -9324,13 +10219,13 @@ snapshots: - uploadthing - vite - '@nuxt/icon@2.2.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': + '@nuxt/icon@2.2.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': dependencies: '@iconify/collections': 1.0.653 '@iconify/types': 2.0.0 '@iconify/utils': 3.1.0 '@iconify/vue': 5.0.0(vue@3.5.27(typescript@5.9.3)) - '@nuxt/devtools-kit': 3.2.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2)) + '@nuxt/devtools-kit': 3.2.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@nuxt/kit': 4.3.1 consola: 3.4.2 local-pkg: 1.1.2 @@ -9404,20 +10299,20 @@ snapshots: pkg-types: 2.3.0 std-env: 3.10.0 - '@nuxt/ui@4.4.0(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(axios@1.13.5)(embla-carousel@8.6.0)(sortablejs@1.14.0)(tailwindcss@4.2.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.29)(zod@4.3.6)': + '@nuxt/ui@4.4.0(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(axios@1.13.5)(embla-carousel@8.6.0)(sortablejs@1.14.0)(tailwindcss@4.2.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.29)(zod@4.3.6)': dependencies: '@floating-ui/dom': 1.7.4 '@iconify/vue': 5.0.0(vue@3.5.27(typescript@5.9.3)) '@internationalized/date': 3.11.0 '@internationalized/number': 3.6.5 - '@nuxt/fonts': 0.12.1(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2)) - '@nuxt/icon': 2.2.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) + '@nuxt/fonts': 0.12.1(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/icon': 2.2.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) '@nuxt/kit': 4.3.1 '@nuxt/schema': 4.3.1 '@nuxtjs/color-mode': 3.5.2 '@standard-schema/spec': 1.1.0 '@tailwindcss/postcss': 4.2.0 - '@tailwindcss/vite': 4.2.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2)) + '@tailwindcss/vite': 4.2.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/vue-table': 8.21.3(vue@3.5.27(typescript@5.9.3)) '@tanstack/vue-virtual': 3.13.18(vue@3.5.27(typescript@5.9.3)) '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) @@ -9526,6 +10421,9 @@ snapshots: transitivePeerDependencies: - magicast + '@opentelemetry/api@1.9.0': + optional: true + '@oven/bun-darwin-aarch64@1.3.6': optional: true @@ -9891,12 +10789,12 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.2.0 - '@tailwindcss/vite@4.2.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2))': + '@tailwindcss/vite@4.2.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.2.0 '@tailwindcss/oxide': 4.2.0 tailwindcss: 4.2.0 - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@tanstack/table-core@8.21.3': {} @@ -10232,6 +11130,9 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.3.0 + '@types/caseless@0.12.5': + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -10430,6 +11331,9 @@ snapshots: '@types/linkify-it@5.0.0': {} + '@types/long@4.0.2': + optional: true + '@types/mapbox__point-geometry@0.1.4': {} '@types/mapbox__vector-tile@1.3.4': @@ -10510,6 +11414,14 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 22.19.7 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.5 + optional: true + '@types/resolve@1.20.2': {} '@types/semver@7.7.1': {} @@ -10533,7 +11445,7 @@ snapshots: '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 25.3.0 + '@types/node': 22.19.7 '@types/ssh2@0.5.52': dependencies: @@ -10579,6 +11491,9 @@ snapshots: '@types/topojson-simplify': 3.0.3 '@types/topojson-specification': 1.0.5 + '@types/tough-cookie@4.0.5': + optional: true + '@types/web-bluetooth@0.0.20': {} '@types/web-bluetooth@0.0.21': {} @@ -10787,10 +11702,10 @@ snapshots: vite: 5.4.21(@types/node@22.19.7)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0) vue: 3.5.27(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.27(typescript@5.9.3) '@vitest/expect@2.1.9': @@ -10816,13 +11731,13 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.19.7)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@2.1.9': dependencies: @@ -11143,6 +12058,9 @@ snapshots: - supports-color optional: true + agent-base@7.1.4: + optional: true + aggregate-error@3.1.0: dependencies: clean-stack: 2.2.0 @@ -11248,6 +12166,9 @@ snapshots: arrify@1.0.1: {} + arrify@2.0.1: + optional: true + asap@2.0.6: {} asn1@0.2.6: @@ -11260,6 +12181,11 @@ snapshots: async-lock@1.4.1: {} + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + optional: true + async@3.2.6: {} asynckit@0.4.0: {} @@ -11386,6 +12312,9 @@ snapshots: big-integer@1.6.52: {} + bignumber.js@9.3.1: + optional: true + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -12224,23 +13153,14 @@ snapshots: dotenv@17.3.1: {} - drizzle-arktype@0.1.3(arktype@2.1.20)(drizzle-orm@0.44.7(@types/pg@8.16.0)(pg@8.17.1)): + drizzle-arktype@0.1.3(arktype@2.1.20)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.20.0)): dependencies: arktype: 2.1.20 - drizzle-orm: 0.44.7(@types/pg@8.16.0)(pg@8.17.1) + drizzle-orm: 0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.20.0) - drizzle-arktype@0.1.3(arktype@2.1.20)(drizzle-orm@0.44.7(@types/pg@8.16.0)(pg@8.20.0)): - dependencies: - arktype: 2.1.20 - drizzle-orm: 0.44.7(@types/pg@8.16.0)(pg@8.20.0) - - drizzle-orm@0.44.7(@types/pg@8.16.0)(pg@8.17.1): - optionalDependencies: - '@types/pg': 8.16.0 - pg: 8.17.1 - - drizzle-orm@0.44.7(@types/pg@8.16.0)(pg@8.20.0): + drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.20.0): optionalDependencies: + '@opentelemetry/api': 1.9.0 '@types/pg': 8.16.0 pg: 8.20.0 @@ -12250,6 +13170,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + optional: true + earcut@2.2.4: {} eastasianwidth@0.2.0: {} @@ -12634,6 +13562,11 @@ snapshots: exsolve@1.0.8: {} + extend@3.0.2: + optional: true + + farmhash-modern@1.1.0: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -12650,10 +13583,26 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.1.3 + optional: true + + fast-xml-parser@5.5.6: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.1.3 + strnum: 2.2.0 + optional: true + fastq@1.20.1: dependencies: reusify: 1.1.0 + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.4 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -12698,6 +13647,57 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + firebase-admin@12.7.0: + dependencies: + '@fastify/busboy': 3.2.0 + '@firebase/database-compat': 1.0.8 + '@firebase/database-types': 1.0.5 + '@types/node': 22.19.7 + farmhash-modern: 1.1.0 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2 + node-forge: 1.3.3 + uuid: 10.0.0 + optionalDependencies: + '@google-cloud/firestore': 7.11.6 + '@google-cloud/storage': 7.19.0 + transitivePeerDependencies: + - encoding + - supports-color + + firebase@12.10.0: + dependencies: + '@firebase/ai': 2.9.0(@firebase/app-types@0.9.3)(@firebase/app@0.14.9) + '@firebase/analytics': 0.10.20(@firebase/app@0.14.9) + '@firebase/analytics-compat': 0.2.26(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/app': 0.14.9 + '@firebase/app-check': 0.11.1(@firebase/app@0.14.9) + '@firebase/app-check-compat': 0.4.1(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/app-compat': 0.5.9 + '@firebase/app-types': 0.9.3 + '@firebase/auth': 1.12.1(@firebase/app@0.14.9) + '@firebase/auth-compat': 0.6.3(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9) + '@firebase/data-connect': 0.4.0(@firebase/app@0.14.9) + '@firebase/database': 1.1.1 + '@firebase/database-compat': 2.1.1 + '@firebase/firestore': 4.12.0(@firebase/app@0.14.9) + '@firebase/firestore-compat': 0.4.6(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9) + '@firebase/functions': 0.13.2(@firebase/app@0.14.9) + '@firebase/functions-compat': 0.4.2(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/installations': 0.6.20(@firebase/app@0.14.9) + '@firebase/installations-compat': 0.2.20(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9) + '@firebase/messaging': 0.12.24(@firebase/app@0.14.9) + '@firebase/messaging-compat': 0.2.24(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/performance': 0.7.10(@firebase/app@0.14.9) + '@firebase/performance-compat': 0.2.23(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/remote-config': 0.8.1(@firebase/app@0.14.9) + '@firebase/remote-config-compat': 0.2.22(@firebase/app-compat@0.5.9)(@firebase/app@0.14.9) + '@firebase/storage': 0.14.1(@firebase/app@0.14.9) + '@firebase/storage-compat': 0.4.1(@firebase/app-compat@0.5.9)(@firebase/app-types@0.9.3)(@firebase/app@0.14.9) + '@firebase/util': 1.14.0 + transitivePeerDependencies: + - '@react-native-async-storage/async-storage' + flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -12729,7 +13729,7 @@ snapshots: unicode-properties: 1.4.1 unicode-trie: 2.0.0 - fontless@0.1.0(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2)): + fontless@0.1.0(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 css-tree: 3.1.0 @@ -12745,7 +13745,7 @@ snapshots: unifont: 0.6.0 unstorage: 1.17.4(@capacitor/preferences@8.0.1(@capacitor/core@8.1.0)) optionalDependencies: - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -12776,6 +13776,16 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@2.5.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + optional: true + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -12841,8 +13851,33 @@ snapshots: function-bind@1.1.2: {} + functional-red-black-tree@1.0.1: + optional: true + fuse.js@7.1.0: {} + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -12884,6 +13919,10 @@ snapshots: get-stream@8.0.1: {} + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + giget@2.0.0: dependencies: citty: 0.1.6 @@ -12975,6 +14014,41 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + google-gax@4.6.1: + dependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.7.15 + '@types/long': 4.0.2 + abort-controller: 3.0.0 + duplexify: 4.1.3 + google-auth-library: 9.15.1 + node-fetch: 2.7.0 + object-hash: 3.0.0 + proto3-json-serializer: 2.0.2 + protobufjs: 7.5.4 + retry-request: 7.0.2 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + google-logging-utils@0.0.2: + optional: true + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -12983,6 +14057,15 @@ snapshots: dependencies: lodash.merge: 4.6.2 + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + h3@1.15.5: dependencies: cookie-es: 1.2.2 @@ -13045,6 +14128,9 @@ snapshots: whatwg-encoding: 2.0.0 optional: true + html-entities@2.6.0: + optional: true + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -13053,6 +14139,8 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-parser-js@0.5.10: {} + http-proxy-agent@5.0.0: dependencies: '@tootallnate/once': 2.0.0 @@ -13070,6 +14158,14 @@ snapshots: - supports-color optional: true + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + human-signals@5.0.0: {} iconv-lite@0.4.24: @@ -13080,6 +14176,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@7.1.1: {} + ieee754@1.1.13: {} ieee754@1.2.1: {} @@ -13216,6 +14314,8 @@ snapshots: jmespath@0.16.0: {} + jose@4.15.9: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -13260,6 +14360,11 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + optional: true + json-buffer@3.0.1: {} json-parse-better-errors@1.0.2: {} @@ -13307,6 +14412,16 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@3.2.2: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 4.15.9 + limiter: 1.1.5 + lru-memoizer: 2.3.0 + transitivePeerDependencies: + - supports-color + jws@4.0.1: dependencies: jwa: 2.0.1 @@ -13394,6 +14509,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.31.1 lightningcss-win32-x64-msvc: 1.31.1 + limiter@1.1.5: {} + lines-and-columns@1.2.4: {} linkify-it@5.0.0: @@ -13432,6 +14549,8 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.clonedeep@4.5.0: {} + lodash.debounce@4.0.8: {} lodash.includes@4.3.0: {} @@ -13472,6 +14591,11 @@ snapshots: dependencies: yallist: 4.0.0 + lru-memoizer@2.3.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 6.0.0 + luxon@3.7.2: {} magic-regexp@0.10.0: @@ -13583,6 +14707,9 @@ snapshots: mime@1.6.0: {} + mime@3.0.0: + optional: true + mimic-fn@4.0.0: {} mimic-response@3.1.0: {} @@ -13745,6 +14872,8 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-forge@1.3.3: {} + node-html-parser@5.4.2: dependencies: css-select: 4.3.0 @@ -13793,6 +14922,9 @@ snapshots: object-assign@4.1.1: {} + object-hash@3.0.0: + optional: true + object-inspect@1.13.4: {} obug@2.1.1: {} @@ -13932,6 +15064,9 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.1.3: + optional: true + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -13986,16 +15121,10 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.10.0: {} - pg-connection-string@2.12.0: {} pg-int8@1.0.1: {} - pg-pool@3.11.0(pg@8.17.1): - dependencies: - pg: 8.17.1 - pg-pool@3.13.0(pg@8.20.0): dependencies: pg: 8.20.0 @@ -14012,16 +15141,6 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.17.1: - dependencies: - pg-connection-string: 2.10.0 - pg-pool: 3.11.0(pg@8.17.1) - pg-protocol: 1.11.0 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.3.0 - pg@8.20.0: dependencies: pg-connection-string: 2.12.0 @@ -14272,6 +15391,11 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.10.5 + proto3-json-serializer@2.0.2: + dependencies: + protobufjs: 7.5.4 + optional: true + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -14490,6 +15614,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve-protobuf-schema@2.1.0: dependencies: protocol-buffers-schema: 3.6.0 @@ -14502,8 +15628,21 @@ snapshots: restructure@3.0.2: {} + retry-request@7.0.2: + dependencies: + '@types/request': 2.48.13 + extend: 3.0.2 + teeny-request: 9.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + retry@0.12.0: {} + retry@0.13.1: + optional: true + reusify@1.1.0: {} rimraf@3.0.2: @@ -14810,6 +15949,14 @@ snapshots: stream-buffers@2.2.0: {} + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + optional: true + + stream-shift@1.0.3: + optional: true + streamx@2.23.0: dependencies: events-universal: 1.0.1 @@ -14867,6 +16014,12 @@ snapshots: striptags@3.2.0: {} + strnum@2.2.0: + optional: true + + stubs@3.0.0: + optional: true + stylis@4.2.0: {} supercluster@7.1.5: @@ -14959,6 +16112,18 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + teeny-request@9.0.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + stream-events: 1.0.5 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + temp-dir@2.0.0: {} tempy@1.0.1: @@ -15110,6 +16275,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -15348,6 +16520,9 @@ snapshots: uuid@8.0.0: {} + uuid@9.0.1: + optional: true + v8-compile-cache-lib@3.0.1: {} validate-npm-package-license@3.0.4: @@ -15383,13 +16558,13 @@ snapshots: - supports-color - terser - vite-node@3.2.4(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -15404,7 +16579,7 @@ snapshots: - tsx - yaml - vite-plugin-dts@4.5.4(@types/node@25.3.0)(rollup@4.55.2)(typescript@5.8.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2)): + vite-plugin-dts@4.5.4(@types/node@25.3.0)(rollup@4.55.2)(typescript@5.8.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@microsoft/api-extractor': 7.55.2(@types/node@25.3.0) '@rollup/pluginutils': 5.3.0(rollup@4.55.2) @@ -15417,7 +16592,7 @@ snapshots: magic-string: 0.30.21 typescript: 5.8.3 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - rollup @@ -15444,7 +16619,7 @@ snapshots: sass: 1.97.2 terser: 5.46.0 - vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2): + vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -15459,9 +16634,10 @@ snapshots: lightningcss: 1.31.1 sass: 1.97.2 terser: 5.46.0 + tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -15476,6 +16652,7 @@ snapshots: lightningcss: 1.31.1 sass: 1.97.2 terser: 5.46.0 + tsx: 4.21.0 yaml: 2.8.2 vitest@2.1.9(@types/node@22.19.7)(jsdom@20.0.3)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0): @@ -15514,11 +16691,11 @@ snapshots: - supports-color - terser - vitest@3.2.4(@types/node@25.3.0)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2): + vitest@3.2.4(@types/node@25.3.0)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -15536,8 +16713,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.3.0 @@ -15623,6 +16800,8 @@ snapshots: xml-name-validator: 4.0.0 optional: true + web-vitals@4.2.4: {} + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: @@ -15630,6 +16809,14 @@ snapshots: webpack-virtual-modules@0.6.2: {} + websocket-driver@0.7.4: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + whatwg-encoding@2.0.0: dependencies: iconv-lite: 0.6.3 diff --git a/taskview-packages/taskview-api/src/api/kanban.ts b/taskview-packages/taskview-api/src/api/kanban.ts index 454a9e5..22d9153 100644 --- a/taskview-packages/taskview-api/src/api/kanban.ts +++ b/taskview-packages/taskview-api/src/api/kanban.ts @@ -1,14 +1,25 @@ import TvApiBase from './base'; import type { AppResponse } from '@/api/base.types'; -import type { KanbanArgAddColumn, KanbanArgDeleteColumn, KanbanArgUpdateColumn, KanbanArgUpdateTasksOrder, KanbanColumnItem, KanbanResponseDeleteColumn, KanbanResponseTasksForColumn, KanbanResponseTasksOrderForColumnAndCursor, KanbanResponseUpdateColumn, KanbanResponseUpdateTasksOrder } from './kanban.types'; +import type { KanbanArgAddColumn, KanbanArgDeleteColumn, KanbanArgUpdateColumn, KanbanArgUpdateTasksOrder, KanbanColumnItem, KanbanFilters, KanbanResponseDeleteColumn, KanbanResponseTasksForColumn, KanbanResponseTasksOrderForColumnAndCursor, KanbanResponseUpdateColumn, KanbanResponseUpdateTasksOrder } from './kanban.types'; import type { Task } from './tasks.api.types'; export default class TvKanban extends TvApiBase { protected moduleUrl = '/module/kanban'; - public async fetchTasksForColumn(goalId: number, columnId: number | null, cursor: string | number | null) { + public async fetchTasksForColumn(goalId: number, columnId: number | null, cursor: string | number | null, filters?: KanbanFilters) { + const params = new URLSearchParams(); + if (filters) { + if (filters.listIds && filters.listIds.length > 0) { + params.set('listIds', filters.listIds.join(',')); + } + if (filters.assigneeIds && filters.assigneeIds.length > 0) { + params.set('assigneeIds', filters.assigneeIds.join(',')); + } + } + const query = params.toString(); + const url = `${this.moduleUrl}/tasks/${goalId}/${columnId}/${cursor}${query ? `?${query}` : ''}`; return this.request( - this.$axios.get>(`${this.moduleUrl}/tasks/${goalId}/${columnId}/${cursor}`) + this.$axios.get>(url) ); } diff --git a/taskview-packages/taskview-api/src/api/kanban.types.ts b/taskview-packages/taskview-api/src/api/kanban.types.ts index 541c23b..ed49b5c 100644 --- a/taskview-packages/taskview-api/src/api/kanban.types.ts +++ b/taskview-packages/taskview-api/src/api/kanban.types.ts @@ -1,5 +1,10 @@ import type { Task } from "./tasks.api.types"; +export type KanbanFilters = { + listIds?: number[]; + assigneeIds?: number[]; +}; + export type KanbanColumnItem = { id: number; name: string; diff --git a/taskview-packages/taskview-api/src/api/notifications.api.types.ts b/taskview-packages/taskview-api/src/api/notifications.api.types.ts index e93cd29..7a72666 100644 --- a/taskview-packages/taskview-api/src/api/notifications.api.types.ts +++ b/taskview-packages/taskview-api/src/api/notifications.api.types.ts @@ -1,7 +1,22 @@ +export enum NotificationType { + DEADLINE = 'deadline', + ASSIGN = 'assign', + MENTION = 'mention', + COMMENT = 'comment', + STATUS_CHANGE = 'status_change', +} + +export enum NotificationChannel { + PUSH = 'push', + WEBSOCKET = 'websocket', + EMAIL = 'email', +} + export interface Notification { id: number; userId: number; taskId: number | null; + type: NotificationType; title: string; body: string | null; read: boolean; @@ -20,3 +35,20 @@ export type NotificationResponseConnectionToken = { token: string | null; url: string | null; }; + +type Minutes = string; +type IsEnabled = boolean; +export interface NotificationPreferencesSettings { + global?: Partial>; + intervals?: Record; + }>>; + projects?: Record>; + intervals?: Record; + }>>>; +} + +export type NotificationResponsePreferences = { + settings: NotificationPreferencesSettings; +}; diff --git a/taskview-packages/taskview-api/src/api/notifications.ts b/taskview-packages/taskview-api/src/api/notifications.ts index f17aca0..fc83160 100644 --- a/taskview-packages/taskview-api/src/api/notifications.ts +++ b/taskview-packages/taskview-api/src/api/notifications.ts @@ -1,6 +1,6 @@ import TvApiBase from "./base"; import type { AppResponse } from "./base.types"; -import type { NotificationResponseFetch, NotificationResponseMarkRead, NotificationResponseConnectionToken } from "./notifications.api.types"; +import type { NotificationResponseFetch, NotificationResponseMarkRead, NotificationResponseConnectionToken, NotificationResponsePreferences, NotificationPreferencesSettings } from "./notifications.api.types"; export default class TvNotificationsApi extends TvApiBase { protected moduleUrl = '/module/notifications'; @@ -36,4 +36,36 @@ export default class TvNotificationsApi extends TvApiBase { ) ); } + + public async registerDevice(token: string, platform: 'android' | 'ios', timezone: string) { + return this.request( + this.$axios.post>( + `${this.moduleUrl}/device/register`, { token, platform, timezone } + ) + ); + } + + public async unregisterDevice(token: string) { + return this.request( + this.$axios.post>( + `${this.moduleUrl}/device/unregister`, { token } + ) + ); + } + + public async getPreferences() { + return this.request( + this.$axios.get>( + `${this.moduleUrl}/preferences` + ) + ); + } + + public async savePreferences(settings: NotificationPreferencesSettings) { + return this.request( + this.$axios.put>( + `${this.moduleUrl}/preferences`, { settings } + ) + ); + } } diff --git a/taskview-packages/taskview-api/src/api/webhooks.ts b/taskview-packages/taskview-api/src/api/webhooks.ts new file mode 100644 index 0000000..09bb12e --- /dev/null +++ b/taskview-packages/taskview-api/src/api/webhooks.ts @@ -0,0 +1,62 @@ +import TvApiBase from './base'; +import type { AppResponse } from '@/api/base.types'; +import type { + WebhookArgCreate, + WebhookArgDelete, + WebhookArgUpdate, + WebhookDeliveryItem, + WebhookItem, + WebhookResponseCreate, +} from './webhooks.types'; + +export default class TvWebhooks extends TvApiBase { + protected moduleUrl = '/module/webhooks'; + + public async fetch(goalId: number) { + return this.request( + this.$axios.get>(`${this.moduleUrl}`, { params: { goalId } }) + ); + } + + public async create(data: WebhookArgCreate) { + return this.request( + this.$axios.post>(`${this.moduleUrl}`, data) + ); + } + + public async update(data: WebhookArgUpdate) { + return this.request( + this.$axios.patch>(`${this.moduleUrl}`, data) + ); + } + + public async delete(data: WebhookArgDelete) { + return this.request( + this.$axios.delete>(`${this.moduleUrl}`, { data }) + ); + } + + public async rotateSecret(id: number) { + return this.request( + this.$axios.post>(`${this.moduleUrl}/rotate-secret`, { id }) + ); + } + + public async testDelivery(id: number) { + return this.request( + this.$axios.post>(`${this.moduleUrl}/test`, { id }) + ); + } + + public async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string }) { + return this.request( + this.$axios.get>(`${this.moduleUrl}/deliveries/${webhookId}`, { params: options }) + ); + } + + public async retryDelivery(deliveryId: number) { + return this.request( + this.$axios.post>(`${this.moduleUrl}/retry`, { id: deliveryId }) + ); + } +} diff --git a/taskview-packages/taskview-api/src/api/webhooks.types.ts b/taskview-packages/taskview-api/src/api/webhooks.types.ts new file mode 100644 index 0000000..e433287 --- /dev/null +++ b/taskview-packages/taskview-api/src/api/webhooks.types.ts @@ -0,0 +1,52 @@ +export type WebhookItem = { + id: number; + goalId: number; + url: string; + events: string[]; + isActive: boolean; + createdAt: string | null; + updatedAt: string | null; +}; + +export type WebhookArgCreate = { + goalId: number; + url: string; + events: string[]; +}; + +export type WebhookResponseCreate = { + webhook: WebhookItem; + secret: string; +}; + +export type WebhookArgUpdate = { + id: number; + url?: string; + events?: string[]; + isActive?: boolean; +}; + +export type WebhookArgDelete = { + id: number; +}; + +export type WebhookDeliveryItem = { + id: number; + webhookId: number; + event: string; + payload: unknown; + status: string; + responseCode: number | null; + attempts: number; + lastAttemptAt: string | null; + createdAt: string | null; +}; + +export const WEBHOOK_EVENTS = [ + 'task.created', + 'task.updated', + 'task.deleted', + 'task.assigneesChanged', +] as const; + +export type WebhookEvent = typeof WEBHOOK_EVENTS[number]; diff --git a/taskview-packages/taskview-api/src/index.ts b/taskview-packages/taskview-api/src/index.ts index 963e54c..bb78dff 100644 --- a/taskview-packages/taskview-api/src/index.ts +++ b/taskview-packages/taskview-api/src/index.ts @@ -10,4 +10,5 @@ export * from '@/api/tags.api.types'; export * from '@/api/goals-list.types'; export * from '@/api/kanban.types'; export * from '@/api/integrations.types'; -export * from '@/api/notifications.api.types'; \ No newline at end of file +export * from '@/api/notifications.api.types'; +export * from '@/api/webhooks.types'; \ No newline at end of file diff --git a/taskview-packages/taskview-api/src/tv.ts b/taskview-packages/taskview-api/src/tv.ts index e6a68d9..cb0c434 100644 --- a/taskview-packages/taskview-api/src/tv.ts +++ b/taskview-packages/taskview-api/src/tv.ts @@ -8,6 +8,7 @@ import TvTagsApi from "./api/tags"; import TvIntegrationsApi from "./api/integrations"; import TvKanban from "./api/kanban"; import TvNotificationsApi from "./api/notifications"; +import TvWebhooks from "./api/webhooks"; export class TvApi { @@ -31,6 +32,8 @@ export class TvApi { public notifications: TvNotificationsApi; + public webhooks: TvWebhooks; + constructor($axios: AxiosInstance) { this.$axios = $axios; @@ -51,6 +54,8 @@ export class TvApi { this.integrations = new TvIntegrationsApi(this.$axios); this.notifications = new TvNotificationsApi(this.$axios); + + this.webhooks = new TvWebhooks(this.$axios); } public setBaseUrl(baseUrl: string) { diff --git a/taskview-packages/taskview-db-schemas/package.json b/taskview-packages/taskview-db-schemas/package.json index 5988beb..a00a233 100644 --- a/taskview-packages/taskview-db-schemas/package.json +++ b/taskview-packages/taskview-db-schemas/package.json @@ -23,15 +23,16 @@ "type-check": "tsc --noEmit" }, "devDependencies": { + "drizzle-orm": "^0.44.4", "typescript": "~5.8.3", "vite": "^7.0.4" }, "dependencies": { - "drizzle-orm": "^0.44.4", "vite-plugin-dts": "^4.5.4" }, "peerDependencies": { "arktype": "2.1.20", - "drizzle-arktype": "^0.1.3" + "drizzle-arktype": "^0.1.3", + "drizzle-orm": "^0.44.4" } } \ No newline at end of file diff --git a/taskview-packages/taskview-db-schemas/src/index.ts b/taskview-packages/taskview-db-schemas/src/index.ts index 4249b8b..3545c2c 100644 --- a/taskview-packages/taskview-db-schemas/src/index.ts +++ b/taskview-packages/taskview-db-schemas/src/index.ts @@ -10,3 +10,6 @@ export * from './schemas/goals-list.schema'; export * from './schemas/tasks-statuses.schema'; export * from './schemas/integrations.schema'; export * from './schemas/notifications.schema'; +export * from './schemas/device-tokens.schema'; +export * from './schemas/notification-preferences.schema'; +export * from './schemas/webhooks.schema'; diff --git a/taskview-packages/taskview-db-schemas/src/schemas/device-tokens.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/device-tokens.schema.ts new file mode 100644 index 0000000..c88b18e --- /dev/null +++ b/taskview-packages/taskview-db-schemas/src/schemas/device-tokens.schema.ts @@ -0,0 +1,14 @@ +import { integer, pgSchema, varchar, timestamp } from "drizzle-orm/pg-core"; +import { UsersSchema } from "./users.schema"; + +export const DeviceTokensSchema = pgSchema('tasks').table('device_tokens', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + userId: integer('user_id').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }), + token: varchar({ length: 500 }).notNull(), + platform: varchar({ length: 20 }).notNull(), + timezone: varchar({ length: 50 }).notNull().default('UTC'), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); + +export type DeviceTokensSchemaTypeForSelect = typeof DeviceTokensSchema.$inferSelect; +export type DeviceTokensSchemaTypeForInsert = typeof DeviceTokensSchema.$inferInsert; diff --git a/taskview-packages/taskview-db-schemas/src/schemas/notification-preferences.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/notification-preferences.schema.ts new file mode 100644 index 0000000..137498f --- /dev/null +++ b/taskview-packages/taskview-db-schemas/src/schemas/notification-preferences.schema.ts @@ -0,0 +1,10 @@ +import { integer, pgSchema, jsonb } from "drizzle-orm/pg-core"; +import { UsersSchema } from "./users.schema"; + +export const NotificationPreferencesSchema = pgSchema('tasks').table('notification_preferences', { + userId: integer('user_id').primaryKey().references(() => UsersSchema.id, { onDelete: 'cascade' }), + settings: jsonb().notNull().default({}), +}); + +export type NotificationPreferencesSchemaTypeForSelect = typeof NotificationPreferencesSchema.$inferSelect; +export type NotificationPreferencesSchemaTypeForInsert = typeof NotificationPreferencesSchema.$inferInsert; diff --git a/taskview-packages/taskview-db-schemas/src/schemas/notifications.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/notifications.schema.ts index 245258a..5fb8b7a 100644 --- a/taskview-packages/taskview-db-schemas/src/schemas/notifications.schema.ts +++ b/taskview-packages/taskview-db-schemas/src/schemas/notifications.schema.ts @@ -6,6 +6,7 @@ export const NotificationsSchema = pgSchema('tasks').table('notifications', { id: integer().primaryKey().generatedAlwaysAsIdentity(), userId: integer('user_id').notNull().references(() => UsersSchema.id, { onDelete: 'cascade' }), taskId: integer('task_id').references(() => TasksSchema.id, { onDelete: 'set null' }), + type: varchar({ length: 50 }).notNull().default('deadline'), title: varchar({ length: 255 }).notNull(), body: varchar({ length: 1000 }), read: boolean().notNull().default(false), diff --git a/taskview-packages/taskview-db-schemas/src/schemas/webhooks.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/webhooks.schema.ts new file mode 100644 index 0000000..1146406 --- /dev/null +++ b/taskview-packages/taskview-db-schemas/src/schemas/webhooks.schema.ts @@ -0,0 +1,30 @@ +import { boolean, integer, jsonb, pgSchema, timestamp, varchar } from "drizzle-orm/pg-core"; +import { GoalsSchema } from "./goals.schema"; + +export const WebhooksSchema = pgSchema('tasks').table('webhooks', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + goalId: integer('goal_id').notNull().references(() => GoalsSchema.id, { onDelete: 'cascade' }), + url: varchar({ length: 500 }).notNull(), + secretEncrypted: varchar('secret_encrypted').notNull(), + events: varchar().array().notNull().default([]), + isActive: boolean('is_active').notNull().default(true), + consecutiveFailures: integer('consecutive_failures').notNull().default(0), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +export const WebhookDeliveriesSchema = pgSchema('tasks').table('webhook_deliveries', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + webhookId: integer('webhook_id').notNull().references(() => WebhooksSchema.id, { onDelete: 'cascade' }), + event: varchar({ length: 50 }).notNull(), + payload: jsonb().notNull(), + status: varchar({ length: 20 }).notNull().default('pending'), + responseCode: integer('response_code'), + attempts: integer().notNull().default(0), + lastAttemptAt: timestamp('last_attempt_at'), + createdAt: timestamp('created_at').defaultNow(), +}); + +export type WebhooksSchemaTypeForSelect = typeof WebhooksSchema.$inferSelect; +export type WebhooksSchemaTypeForInsert = typeof WebhooksSchema.$inferInsert; +export type WebhookDeliveriesSchemaTypeForSelect = typeof WebhookDeliveriesSchema.$inferSelect; diff --git a/web/android/app/google-services.json b/web/android/app/google-services.json new file mode 100644 index 0000000..c5a9032 --- /dev/null +++ b/web/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "56915832145", + "project_id": "taskview-60696", + "storage_bucket": "taskview-60696.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:56915832145:android:d41d716f9c4d5ae3e77770", + "android_client_info": { + "package_name": "com.handscreamgnl.taskview.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAuoLpIkde2DJGihz-XDPKqGG2uPYUew60" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/web/android/app/src/main/AndroidManifest.xml b/web/android/app/src/main/AndroidManifest.xml index fbde4da..2602d18 100644 --- a/web/android/app/src/main/AndroidManifest.xml +++ b/web/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,10 @@ android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/AppTheme"> + + + + + + aps-environment + development + + diff --git a/web/ios/App/App/AppDelegate.swift b/web/ios/App/App/AppDelegate.swift index c3cd83b..f640c3a 100644 --- a/web/ios/App/App/AppDelegate.swift +++ b/web/ios/App/App/AppDelegate.swift @@ -46,4 +46,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) } +// PUSH NOTIFICATIONS + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + NotificationCenter.default.post( + name: .capacitorDidRegisterForRemoteNotifications, + object: deviceToken) + } + + func application(_ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error) { + NotificationCenter.default.post( + name: .capacitorDidFailToRegisterForRemoteNotifications, + object: error) + } + } diff --git a/web/ios/App/CapApp-SPM/Package.swift b/web/ios/App/CapApp-SPM/Package.swift index 457de47..da91ba5 100644 --- a/web/ios/App/CapApp-SPM/Package.swift +++ b/web/ios/App/CapApp-SPM/Package.swift @@ -12,10 +12,12 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.1.0"), + .package(name: "CapacitorFirebaseMessaging", path: "../../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.10.0/node_modules/@capacitor-firebase/messaging"), .package(name: "CapacitorApp", path: "../../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app"), .package(name: "CapacitorBrowser", path: "../../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser"), .package(name: "CapacitorDevice", path: "../../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device"), .package(name: "CapacitorPreferences", path: "../../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences"), + .package(name: "CapacitorPushNotifications", path: "../../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.2_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications"), .package(name: "CapacitorSplashScreen", path: "../../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen"), .package(name: "CapgoCapacitorUpdater", path: "../../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.8_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater") ], @@ -25,10 +27,12 @@ let package = Package( dependencies: [ .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorFirebaseMessaging", package: "CapacitorFirebaseMessaging"), .product(name: "CapacitorApp", package: "CapacitorApp"), .product(name: "CapacitorBrowser", package: "CapacitorBrowser"), .product(name: "CapacitorDevice", package: "CapacitorDevice"), .product(name: "CapacitorPreferences", package: "CapacitorPreferences"), + .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"), .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), .product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater") ] diff --git a/web/ios/App/GoogleService-Info.plist b/web/ios/App/GoogleService-Info.plist new file mode 100644 index 0000000..18d2982 --- /dev/null +++ b/web/ios/App/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyBWOhsTMJJYeXdOjT1yMxhzpSY59Ub3i5g + GCM_SENDER_ID + 56915832145 + PLIST_VERSION + 1 + BUNDLE_ID + com.handscream.taskview.app + PROJECT_ID + taskview-60696 + STORAGE_BUCKET + taskview-60696.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:56915832145:ios:37ef8848ef493495e77770 + + \ No newline at end of file diff --git a/web/package.json b/web/package.json index 5e578c3..b5dae88 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "test:e2e:skip-build": "SKIP_BUILD=1 playwright test" }, "dependencies": { + "@capacitor-firebase/messaging": "^8.1.0", "@capacitor/android": "^8.1.0", "@capacitor/app": "8.0.1", "@capacitor/browser": "^8.0.1", @@ -27,6 +28,7 @@ "@capacitor/device": "8.0.1", "@capacitor/ios": "^8.1.0", "@capacitor/preferences": "8.0.1", + "@capacitor/push-notifications": "^8.0.2", "@capacitor/splash-screen": "^8.0.1", "@capgo/capacitor-updater": "^8.43.2", "@dagrejs/dagre": "^1.1.5", @@ -58,6 +60,7 @@ "axios": "^1.13.4", "centrifuge": "^5.5.3", "date-fns": "^4.1.0", + "firebase": "^12.10.0", "pinia": "^2.3.1", "qs": "^6.14.1", "scule": "^1.3.0", diff --git a/web/src/components/NotificationBell.vue b/web/src/components/NotificationBell.vue index 1433815..346c63f 100644 --- a/web/src/components/NotificationBell.vue +++ b/web/src/components/NotificationBell.vue @@ -1,78 +1,86 @@ diff --git a/web/src/pages/user/kanban.vue b/web/src/pages/user/kanban.vue index 64d98d7..e151487 100644 --- a/web/src/pages/user/kanban.vue +++ b/web/src/pages/user/kanban.vue @@ -8,7 +8,38 @@ + +
+
+ +
+
diff --git a/web/src/pages/user/webhooks.vue b/web/src/pages/user/webhooks.vue new file mode 100644 index 0000000..22e645c --- /dev/null +++ b/web/src/pages/user/webhooks.vue @@ -0,0 +1,21 @@ + + + diff --git a/web/src/plugins/axios.ts b/web/src/plugins/axios.ts index b58ba57..d0d1a98 100644 --- a/web/src/plugins/axios.ts +++ b/web/src/plugins/axios.ts @@ -3,6 +3,7 @@ import { TvApi } from 'taskview-api' import type { App } from 'vue' import $api from '@/helpers/axios' import LocalStorage from '@/helpers/LocalStorage' +import { LS_KEY_MAIN_SERVER } from '@/composables/useAdditionalServer' let $ls: LocalStorage const $tvApi: TvApi = new TvApi($api) @@ -18,10 +19,13 @@ const api = { $ls = app.config.globalProperties.$ls - // const apiToken = await $ls.getToken(); - // if (apiToken) { - // $tvApi = new TvApi($api); - // } + const savedServer = await $ls.getValue(LS_KEY_MAIN_SERVER) + console.log('savedServer', savedServer, LS_KEY_MAIN_SERVER) + if (savedServer) { + $api.defaults.baseURL = savedServer + $api.defaults.headers.common['ngrok-skip-browser-warning'] = "1" + $tvApi.setBaseUrl(savedServer) + } console.debug('Install axios') await $ls.checkTokenAndSetForAxios() @@ -72,12 +76,12 @@ const api = { return new Promise((resolve, reject) => { $axios .post<{ - access: string; - refresh: string; - }>( - '/module/auth/refresh/token', - qs.stringify({ refreshToken }), - ) + access: string; + refresh: string; + }>( + '/module/auth/refresh/token', + qs.stringify({ refreshToken }), + ) .then((data) => { console.log('We got refresh data tokens', data) $ls.setToken(data.data.access) diff --git a/web/src/stores/graph.store.ts b/web/src/stores/graph.store.ts index c9b5a80..5ffc0a1 100644 --- a/web/src/stores/graph.store.ts +++ b/web/src/stores/graph.store.ts @@ -12,6 +12,8 @@ export const useGraphStore = defineStore('use-graph-store', { return { nodes: [], edges: [], + allNodes: [], + allEdges: [], } }, actions: { @@ -30,24 +32,28 @@ export const useGraphStore = defineStore('use-graph-store', { tasksStore.fetchAllTasks(goalId, 1, 1, true), ]) - this.nodes = tasksStore.tasks.map((task) => ({ + const nodes = tasksStore.tasks.map((task) => ({ id: task.id.toString(), data: { task, label: `${task.description} == ${task.id}` }, position: task.nodeGraphPosition ?? { x: 0, y: 0 }, type: 'task', - style: { resize: 'none' }, + style: { resize: 'none' as const }, })) + this.allNodes = nodes + this.nodes = [...nodes] await this.fetchAllEdges(goalId) }, async addNode(task: TaskItem) { - this.nodes.push({ + const node = { id: task.id.toString(), data: { task, label: `${task.description} == ${task.id}` }, position: task.nodeGraphPosition ?? { x: 0, y: 0 }, type: 'task', - style: { resize: 'none' }, - }) + style: { resize: 'none' as const }, + } + this.allNodes.push({ ...node }) + this.nodes.push(node) return task.id.toString() }, @@ -63,6 +69,7 @@ export const useGraphStore = defineStore('use-graph-store', { target: edge.toTaskId.toString(), } + this.allEdges.push({ ...newEdge }) this.edges.push(newEdge) return newEdge @@ -71,29 +78,29 @@ export const useGraphStore = defineStore('use-graph-store', { async fetchAllEdges(goalId: number) { const result = await $tvApi.graph.fetchAllEdges(goalId).catch((err: unknown) => logError(err)) if (!result) return - this.edges = result.map((edge) => ({ + const edges = result.map((edge) => ({ id: edge.id.toString(), source: edge.fromTaskId.toString(), target: edge.toTaskId.toString(), })) + this.allEdges = edges + this.edges = [...edges] }, removeTask(taskId: number) { const nodeId = taskId.toString() - const nodeIndex = this.nodes.findIndex((n) => n.id === nodeId) - if (nodeIndex !== -1) { - this.nodes.splice(nodeIndex, 1) - } + this.allNodes = this.allNodes.filter((n) => n.id !== nodeId) + this.allEdges = this.allEdges.filter((e) => e.source !== nodeId && e.target !== nodeId) + this.nodes = this.nodes.filter((n) => n.id !== nodeId) this.edges = this.edges.filter((e) => e.source !== nodeId && e.target !== nodeId) }, async deleteEdge(id: number) { const result = await $tvApi.graph.deleteEdge(id).catch((err: unknown) => logError(err)) if (!result) return - const index = this.edges.findIndex((edge) => edge.id === id.toString()) - if (index !== -1) { - this.edges.splice(index, 1) - } + const idStr = id.toString() + this.allEdges = this.allEdges.filter((e) => e.id !== idStr) + this.edges = this.edges.filter((e) => e.id !== idStr) }, syncTask(task: { id: number; description?: string }) { diff --git a/web/src/stores/kanban.store.ts b/web/src/stores/kanban.store.ts index 1895424..ac7ffea 100644 --- a/web/src/stores/kanban.store.ts +++ b/web/src/stores/kanban.store.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import type { GoalItem, KanbanArgDeleteColumn, KanbanArgUpdateColumn, KanbanArgUpdateTasksOrder, KanbanColumnItem, TaskArgAdd, KanbanArgAddColumn } from 'taskview-api' +import type { GoalItem, KanbanArgDeleteColumn, KanbanArgUpdateColumn, KanbanArgUpdateTasksOrder, KanbanColumnItem, KanbanFilters, TaskArgAdd, KanbanArgAddColumn } from 'taskview-api' import { DEFAULT_ID } from '@/types/app.types' import type { KanbanStoreState } from '@/types/kanban.types' import { $tvApi } from '@/plugins/axios' @@ -11,11 +11,12 @@ export const useKanbanStore = defineStore('kanban', { tasksData: {}, statuses: [], goalId: DEFAULT_ID, + filters: {}, } }, actions: { async fetchTasksForColumn(goalId: GoalItem['id'], columnId: KanbanColumnItem['id'] | null, cursor: string | number | null) { - const result = await $tvApi.kanban.fetchTasksForColumn(goalId, columnId, cursor).catch(console.error) + const result = await $tvApi.kanban.fetchTasksForColumn(goalId, columnId, cursor, this.filters).catch(console.error) if (!result) { if (!this.tasksData[columnId ?? DEFAULT_ID]) { @@ -134,14 +135,24 @@ export const useKanbanStore = defineStore('kanban', { }, syncTask(task: TaskItem) { + const targetColumnId = task.statusId ?? DEFAULT_ID + for (const columnId of Object.keys(this.tasksData)) { const column = this.tasksData[Number(columnId)] if (!column) continue - const localTask = column.tasks.find((t) => t.id === task.id) - if (localTask) { - Object.assign(localTask, task) - return + const index = column.tasks.findIndex((t) => t.id === task.id) + if (index === -1) continue + + if (Number(columnId) === targetColumnId) { + Object.assign(column.tasks[index], task) + } else { + column.tasks.splice(index, 1) + const targetColumn = this.tasksData[targetColumnId] + if (targetColumn) { + targetColumn.tasks.unshift(task) + } } + return } }, @@ -157,6 +168,14 @@ export const useKanbanStore = defineStore('kanban', { } }, + async setFilters(filters: KanbanFilters) { + this.filters = filters + this.tasksData = {} + for (const status of this.statuses) { + await this.fetchTasksForColumn(this.goalId, status.id === DEFAULT_ID ? null : status.id, null) + } + }, + async deleteStatus(data: KanbanArgDeleteColumn) { const result = await $tvApi.kanban.deleteColumn(data) if (!result) { diff --git a/web/src/stores/tasks.store.ts b/web/src/stores/tasks.store.ts index b45760c..ae91ac1 100644 --- a/web/src/stores/tasks.store.ts +++ b/web/src/stores/tasks.store.ts @@ -220,7 +220,7 @@ export const useTasksStore = defineStore('tasks', { if (taskResult.parentId) { const subTask = parentTask.subtasks.find((sub) => sub.id === +taskResult.id) if (subTask) { - subTask.complete = taskResult.complete + Object.assign(subTask, taskResult) } } else { //we updating main task @@ -255,6 +255,13 @@ export const useTasksStore = defineStore('tasks', { const localTask = this.tasks.find(({ id }) => id === task.id) if (localTask) { Object.assign(localTask, task) + } else if (task.parentId) { + const parent = this.tasks.find(({ id }) => id === task.parentId) + ?? (this.selectedTask?.id === task.parentId ? this.selectedTask : null) + const subtask = parent?.subtasks.find((s) => s.id === task.id) + if (subtask) { + Object.assign(subtask, task) + } } this.updateSelectedTask(task) return true diff --git a/web/src/stores/webhooks.store.ts b/web/src/stores/webhooks.store.ts new file mode 100644 index 0000000..3fa9795 --- /dev/null +++ b/web/src/stores/webhooks.store.ts @@ -0,0 +1,80 @@ +import { defineStore } from 'pinia' +import type { WebhookItem, WebhookDeliveryItem } from 'taskview-api' +import { $tvApi } from '@/plugins/axios' + +interface WebhooksStoreState { + webhooks: WebhookItem[] + deliveries: WebhookDeliveryItem[] + loading: boolean + deliveriesLoading: boolean +} + +export const useWebhooksStore = defineStore('webhooks', { + state: (): WebhooksStoreState => ({ + webhooks: [], + deliveries: [], + loading: false, + deliveriesLoading: false, + }), + actions: { + async fetchWebhooks(goalId: number): Promise { + if (this.loading) return + this.loading = true + const result = await $tvApi.webhooks.fetch(goalId) + this.webhooks = result || [] + this.loading = false + }, + + async createWebhook(data: { goalId: number; url: string; events: string[] }): Promise<{ webhook: WebhookItem; secret: string } | null> { + const result = await $tvApi.webhooks.create(data) + if (!result) return null + this.webhooks.push(result.webhook) + return result + }, + + async updateWebhook(data: { id: number; url?: string; events?: string[]; isActive?: boolean }): Promise { + const result = await $tvApi.webhooks.update(data) + if (!result) return + const index = this.webhooks.findIndex((w) => w.id === data.id) + if (index !== -1) { + this.webhooks[index] = result + } + }, + + async deleteWebhook(id: number): Promise { + const result = await $tvApi.webhooks.delete({ id }) + if (!result) return + const index = this.webhooks.findIndex((w) => w.id === id) + if (index !== -1) { + this.webhooks.splice(index, 1) + } + }, + + async rotateSecret(id: number): Promise { + const result = await $tvApi.webhooks.rotateSecret(id) + return result?.secret ?? null + }, + + async testDelivery(id: number): Promise<{ success: boolean; responseCode?: number } | null> { + return await $tvApi.webhooks.testDelivery(id) + }, + + async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string; append?: boolean }): Promise { + this.deliveriesLoading = true + const result = await $tvApi.webhooks.fetchDeliveries(webhookId, { + cursor: options?.cursor, + status: options?.status, + }) + if (options?.append) { + this.deliveries.push(...(result || [])) + } else { + this.deliveries = result || [] + } + this.deliveriesLoading = false + }, + + async retryDelivery(deliveryId: number): Promise<{ success: boolean; responseCode?: number } | null> { + return await $tvApi.webhooks.retryDelivery(deliveryId) + }, + }, +}) diff --git a/web/src/types/graph.types.ts b/web/src/types/graph.types.ts index 22c7dcf..c73cd9c 100644 --- a/web/src/types/graph.types.ts +++ b/web/src/types/graph.types.ts @@ -3,4 +3,6 @@ import type { Edge, Node } from '@vue-flow/core' export type GraphStore = { nodes: Node[]; edges: Edge[]; + allNodes: Node[]; + allEdges: Edge[]; }; diff --git a/web/src/types/kanban.types.ts b/web/src/types/kanban.types.ts index cf69bd4..0b4bcce 100644 --- a/web/src/types/kanban.types.ts +++ b/web/src/types/kanban.types.ts @@ -1,4 +1,4 @@ -import type { GoalItem, KanbanColumnItem, KanbanResponseTasksForColumn } from 'taskview-api' +import type { GoalItem, KanbanColumnItem, KanbanFilters, KanbanResponseTasksForColumn } from 'taskview-api' export type KanbanStoreState = { tasksData: { @@ -7,4 +7,5 @@ export type KanbanStoreState = { // TODO: rename to columns statuses: KanbanColumnItem[]; goalId: GoalItem['id']; + filters: KanbanFilters; }; \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts index b81dcae..8dbda2f 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -30,6 +30,9 @@ export default defineConfig({ vue(), ui({ ui: { + inputTime: { + slots: { base: 'text-base!' } + }, drawer: { slots: { container: 'pb-[calc(var(--tv-safe-area-inset-bottom)+16px)]' @@ -153,4 +156,7 @@ export default defineConfig({ '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, + server: { + port: 5174 + } })