mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-10 22:25:56 +00:00
wip: notifications
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface Dispatcher {
|
||||
register(): void;
|
||||
registerWorkers(): Promise<void>;
|
||||
}
|
||||
@@ -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<string, unknown>; 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<string, unknown>; initiatorId: number };
|
||||
'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number };
|
||||
'task.deleted': { taskId: number; goalId: number; initiatorId: number };
|
||||
}
|
||||
|
||||
type EventName = keyof AppEvents;
|
||||
|
||||
@@ -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<void> {
|
||||
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}"`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks.device_tokens
|
||||
ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) NOT NULL DEFAULT 'UTC';
|
||||
@@ -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 '{}'
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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<string, RoutableConstructor> = {
|
||||
'/module/graph': GraphRoutes,
|
||||
'/module/integrations': IntegrationsRoutes,
|
||||
'/module/notifications': NotificationsRoutes,
|
||||
'/module/webhooks': WebhooksRoutes,
|
||||
};
|
||||
|
||||
export default routes;
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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<void> {
|
||||
await this.cleanupWorker();
|
||||
await this.deadlineScheduler.registerWorker();
|
||||
}
|
||||
|
||||
private async cleanupWorker(): Promise<void> {
|
||||
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<void> {
|
||||
if (data.task.endDate) {
|
||||
await this.deadlineScheduler.schedule(data.task, data.initiatorId);
|
||||
}
|
||||
}
|
||||
|
||||
private async onTaskUpdated(data: AppEvents['task.updated']): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.notificationsRepo.deleteByTaskAndType(data.taskId, NotificationType.DEADLINE);
|
||||
await this.deadlineScheduler.cancel(data.taskId);
|
||||
}
|
||||
|
||||
private async resolveUserName(userId: number): Promise<string> {
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
@@ -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<NotificationChannel, NotificationProvider>;
|
||||
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<void> {
|
||||
$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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T extends NotificationType>(type: T): TypeSettingsMap[T] | undefined {
|
||||
return this.data.global?.[type] as TypeSettingsMap[T] | undefined;
|
||||
}
|
||||
|
||||
getProjectTypeSettings<T extends NotificationType>(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,
|
||||
}
|
||||
}
|
||||
: {}
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<DeadlineJobData>(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<number>();
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
await getCentrifugoClient().publishToUser(userId, 'notification', {
|
||||
notification,
|
||||
goalId: meta.goalId,
|
||||
goalListId: meta.goalListId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<DeviceTokensSchemaTypeForSelect[]> {
|
||||
return await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select()
|
||||
.from(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.userId, userId))
|
||||
) || [];
|
||||
}
|
||||
|
||||
async getTimezoneByUserId(userId: number): Promise<string> {
|
||||
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<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(DeviceTokensSchema)
|
||||
.where(eq(DeviceTokensSchema.token, token))
|
||||
);
|
||||
return !!result;
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -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<NotificationsSchemaTypeForSelect | false> {
|
||||
async create(data: { userId: number; taskId: number | null; type: string; title: string; body: string | null }): Promise<NotificationsSchemaTypeForSelect | false> {
|
||||
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<boolean> {
|
||||
async deleteByTaskAndType(taskId: number, type: string): Promise<boolean> {
|
||||
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;
|
||||
@@ -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<UserPreferences> {
|
||||
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<boolean> {
|
||||
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<NotificationChannel[]> {
|
||||
const prefs = await this.load(userId);
|
||||
return prefs.getEnabledChannels(type, goalId);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await cancelJobBySingletonKey(DEADLINE_JOB, this.singletonKey(taskId));
|
||||
}
|
||||
|
||||
async registerWorker(): Promise<void> {
|
||||
const boss = getJobQueue();
|
||||
const db = Database.getInstance();
|
||||
|
||||
await boss.createQueue(DEADLINE_JOB);
|
||||
|
||||
await boss.work<DeadlineJobData>(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<number[] | null> {
|
||||
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<number>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Record<NotificationChannel, boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, { [K in NotificationType]?: TypeSettingsMap[K] }>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<TasksSchemaTypeForSelect[]> {
|
||||
async fetchTasksForKanbanColumn(goalId: number, columnId: number | null, cursor: number | null, filters?: KanbanArgFilters): Promise<TasksSchemaTypeForSelect[]> {
|
||||
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<number | null> {
|
||||
async fetchTaskWithMinKanbanOrder(goalId: number, columnId?: number | null): Promise<number | null> {
|
||||
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<number>`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<number> {
|
||||
const min = await this.fetchTaskWithMinKanbanOrder(goalId, statusId);
|
||||
async getNextKanbanOrder(goalId: number, columnId?: number | null): Promise<number> {
|
||||
const min = await this.fetchTaskWithMinKanbanOrder(goalId, columnId);
|
||||
return (min ?? 0) - TasksRepository.KANBAN_ORDER_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
const boss = getJobQueue();
|
||||
await boss.createQueue(WEBHOOK_DELIVER_JOB);
|
||||
await boss.work<WebhookDeliverJobData>(WEBHOOK_DELIVER_JOB, async ([job]) => {
|
||||
await this.deliverJob(job.data);
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatch(event: string, goalId: number, payload: object): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<WebhooksSchemaTypeForSelect, 'secretEncrypted'>;
|
||||
|
||||
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<WebhookForClient | null> {
|
||||
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<boolean> {
|
||||
return this.repository.delete(id);
|
||||
}
|
||||
|
||||
async fetchByGoalId(goalId: number): Promise<WebhookForClient[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<WebhooksSchemaTypeForSelect | null> {
|
||||
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<WebhooksSchemaTypeForSelect | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(WebhooksSchema).where(eq(WebhooksSchema.id, id))
|
||||
);
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
async fetchById(id: number): Promise<WebhooksSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(eq(WebhooksSchema.id, id))
|
||||
);
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
async fetchByGoalId(goalId: number): Promise<WebhooksSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(WebhooksSchema).where(eq(WebhooksSchema.goalId, goalId))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async fetchActiveByGoalIdAndEvent(goalId: number, event: string): Promise<WebhooksSchemaTypeForSelect[]> {
|
||||
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<WebhookDeliveriesSchemaTypeForSelect | null> {
|
||||
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<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhookDeliveriesSchema)
|
||||
.set({ ...data, lastAttemptAt: new Date() })
|
||||
.where(eq(WebhookDeliveriesSchema.id, id))
|
||||
);
|
||||
}
|
||||
|
||||
async fetchDeliveryById(id: number): Promise<WebhookDeliveriesSchemaTypeForSelect | null> {
|
||||
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<WebhookDeliveriesSchemaTypeForSelect[]> {
|
||||
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<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ consecutiveFailures: 0 })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
);
|
||||
}
|
||||
|
||||
async incrementConsecutiveFailures(webhookId: number): Promise<number> {
|
||||
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<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(WebhooksSchema)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(WebhooksSchema.id, webhookId))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof Router>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+1284
-97
File diff suppressed because it is too large
Load Diff
@@ -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<AppResponse<KanbanResponseTasksForColumn>>(`${this.moduleUrl}/tasks/${goalId}/${columnId}/${cursor}`)
|
||||
this.$axios.get<AppResponse<KanbanResponseTasksForColumn>>(url)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<NotificationType, {
|
||||
channels?: Partial<Record<NotificationChannel, IsEnabled>>;
|
||||
intervals?: Record<Minutes, IsEnabled>;
|
||||
}>>;
|
||||
projects?: Record<string, Partial<Record<NotificationType, {
|
||||
channels?: Partial<Record<NotificationChannel, IsEnabled>>;
|
||||
intervals?: Record<Minutes, IsEnabled>;
|
||||
}>>>;
|
||||
}
|
||||
|
||||
export type NotificationResponsePreferences = {
|
||||
settings: NotificationPreferencesSettings;
|
||||
};
|
||||
|
||||
@@ -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<AppResponse<boolean>>(
|
||||
`${this.moduleUrl}/device/register`, { token, platform, timezone }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async unregisterDevice(token: string) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<boolean>>(
|
||||
`${this.moduleUrl}/device/unregister`, { token }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async getPreferences() {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<NotificationResponsePreferences>>(
|
||||
`${this.moduleUrl}/preferences`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async savePreferences(settings: NotificationPreferencesSettings) {
|
||||
return this.request(
|
||||
this.$axios.put<AppResponse<boolean>>(
|
||||
`${this.moduleUrl}/preferences`, { settings }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppResponse<WebhookItem[]>>(`${this.moduleUrl}`, { params: { goalId } })
|
||||
);
|
||||
}
|
||||
|
||||
public async create(data: WebhookArgCreate) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<WebhookResponseCreate>>(`${this.moduleUrl}`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async update(data: WebhookArgUpdate) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<WebhookItem>>(`${this.moduleUrl}`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async delete(data: WebhookArgDelete) {
|
||||
return this.request(
|
||||
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}`, { data })
|
||||
);
|
||||
}
|
||||
|
||||
public async rotateSecret(id: number) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<{ secret: string }>>(`${this.moduleUrl}/rotate-secret`, { id })
|
||||
);
|
||||
}
|
||||
|
||||
public async testDelivery(id: number) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<{ success: boolean; responseCode?: number }>>(`${this.moduleUrl}/test`, { id })
|
||||
);
|
||||
}
|
||||
|
||||
public async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string }) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<WebhookDeliveryItem[]>>(`${this.moduleUrl}/deliveries/${webhookId}`, { params: options })
|
||||
);
|
||||
}
|
||||
|
||||
public async retryDelivery(deliveryId: number) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<{ success: boolean; responseCode?: number }>>(`${this.moduleUrl}/retry`, { id: deliveryId })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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';
|
||||
export * from '@/api/notifications.api.types';
|
||||
export * from '@/api/webhooks.types';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -10,6 +10,10 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
AAA632B22F6BB598007C705D /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AAA632B12F6BB598007C705D /* GoogleService-Info.plist */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -28,6 +29,8 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
AAA632B02F69F429007C705D /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
AAA632B12F6BB598007C705D /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -45,6 +48,7 @@
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AAA632B12F6BB598007C705D /* GoogleService-Info.plist */,
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
@@ -62,6 +66,7 @@
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AAA632B02F69F429007C705D /* App.entitlements */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
@@ -145,6 +150,7 @@
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
AAA632B22F6BB598007C705D /* GoogleService-Info.plist in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -295,6 +301,7 @@
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1.20.1;
|
||||
DEVELOPMENT_TEAM = H2W2SG48JT;
|
||||
@@ -318,6 +325,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1.20.1;
|
||||
DEVELOPMENT_TEAM = H2W2SG48JT;
|
||||
|
||||
+118
-1
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"originHash" : "e5bfee72a3975177edbeb9593e2d4a7624097d5319fbcc2894685c2c76545af8",
|
||||
"originHash" : "8f5bd6b6b1090df0485cca11f072bcf87ec1695afdf1e51b55370128ec9e9f4b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "abseil-cpp-binary",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/abseil-cpp-binary.git",
|
||||
"state" : {
|
||||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
|
||||
"version" : "1.2024072200.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "alamofire",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -10,6 +19,15 @@
|
||||
"version" : "5.11.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "app-check",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
|
||||
"version" : "11.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "bigint",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -28,6 +46,105 @@
|
||||
"version" : "8.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "firebase-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk.git",
|
||||
"state" : {
|
||||
"revision" : "52548902007e7beda99fcdca31f62eccc4befe38",
|
||||
"version" : "12.11.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "google-ads-on-device-conversion-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "7b722132d1f2ab421c88a2e5c384c984cebd0d00",
|
||||
"version" : "3.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleappmeasurement",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state" : {
|
||||
"revision" : "1657f705bc70255ff9d66dfcc697a85db164998f",
|
||||
"version" : "12.11.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googledatatransport",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleDataTransport.git",
|
||||
"state" : {
|
||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
||||
"version" : "10.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleutilities",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
|
||||
"version" : "8.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "grpc-binary",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/grpc-binary.git",
|
||||
"state" : {
|
||||
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
|
||||
"version" : "1.69.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtm-session-fetcher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state" : {
|
||||
"revision" : "a883ddb9fd464216133a5ab441f1ae8995978573",
|
||||
"version" : "5.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "interop-ios-for-google-sdks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||
"state" : {
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||
"version" : "101.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "leveldb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/leveldb.git",
|
||||
"state" : {
|
||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||
"version" : "1.22.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "nanopb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/nanopb.git",
|
||||
"state" : {
|
||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
|
||||
"version" : "2.30910.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "promises",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/promises.git",
|
||||
"state" : {
|
||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
||||
"version" : "2.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "version",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyBWOhsTMJJYeXdOjT1yMxhzpSY59Ub3i5g</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>56915832145</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.handscream.taskview.app</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>taskview-60696</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>taskview-60696.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:56915832145:ios:37ef8848ef493495e77770</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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",
|
||||
|
||||
@@ -1,78 +1,86 @@
|
||||
<template>
|
||||
<UPopover
|
||||
v-model:open="isOpen"
|
||||
:content="{ align: 'end', side: 'right', sideOffset: 8 }"
|
||||
<UButton
|
||||
icon="i-lucide-bell"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xl"
|
||||
class="relative"
|
||||
:aria-label="t('notifications.title')"
|
||||
@click="isOpen = true"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-bell"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xl"
|
||||
class="relative"
|
||||
:aria-label="t('notifications.title')"
|
||||
>
|
||||
<template v-if="notificationsStore.unreadCount > 0" #trailing>
|
||||
<UBadge
|
||||
:label="notificationsStore.unreadCount > 99 ? '99+' : String(notificationsStore.unreadCount)"
|
||||
color="error"
|
||||
size="xs"
|
||||
class="absolute -top-1 -right-1 min-w-5 justify-center"
|
||||
/>
|
||||
</template>
|
||||
</UButton>
|
||||
<template v-if="notificationsStore.unreadCount > 0" #trailing>
|
||||
<UBadge
|
||||
:label="notificationsStore.unreadCount > 99 ? '99+' : String(notificationsStore.unreadCount)"
|
||||
color="error"
|
||||
size="xs"
|
||||
class="absolute -top-1 -right-1 min-w-5 justify-center"
|
||||
/>
|
||||
</template>
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="w-80 max-h-96 flex flex-col">
|
||||
<div class="flex items-center justify-between p-3 border-b border-default">
|
||||
<span class="text-sm font-medium">{{ t('notifications.title') }}</span>
|
||||
<UButton
|
||||
v-if="notificationsStore.unreadCount > 0"
|
||||
:label="t('notifications.markAllRead')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="handleMarkAllRead"
|
||||
/>
|
||||
<UModal
|
||||
v-model:open="isOpen"
|
||||
:fullscreen="isMobile"
|
||||
:ui="{
|
||||
overlay: 'sm:items-center sm:justify-center',
|
||||
content: 'sm:max-w-lg sm:max-h-[80vh] sm:rounded-lg sm:m-auto w-full',
|
||||
body: 'p-0!',
|
||||
footer: 'p-4!',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<h3 class="font-semibold">{{ t('notifications.title') }}</h3>
|
||||
<UButton
|
||||
v-if="notificationsStore.unreadCount > 0"
|
||||
:label="t('notifications.markAllRead')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="handleMarkAllRead"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div
|
||||
v-if="notificationsStore.notifications.length === 0"
|
||||
class="p-6 text-center text-sm text-dimmed"
|
||||
>
|
||||
{{ t('notifications.empty') }}
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div
|
||||
v-if="notificationsStore.notifications.length === 0"
|
||||
class="p-6 text-center text-sm text-dimmed"
|
||||
>
|
||||
{{ t('notifications.empty') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="notification in notificationsStore.notifications"
|
||||
:key="notification.id"
|
||||
class="p-3 border-b border-default last:border-b-0 cursor-pointer hover:bg-elevated/50 transition-colors"
|
||||
:class="{ 'bg-elevated/25': !notification.read }"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<span
|
||||
v-if="!notification.read"
|
||||
class="mt-1.5 size-2 rounded-full bg-primary shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ notification.title }}</p>
|
||||
<p
|
||||
v-if="notification.body"
|
||||
class="text-xs text-dimmed mt-0.5 line-clamp-2"
|
||||
>
|
||||
{{ notification.body }}
|
||||
</p>
|
||||
<p class="text-xs text-dimmed mt-1">
|
||||
{{ formatDate(notification.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="notification in notificationsStore.notifications"
|
||||
:key="notification.id"
|
||||
class="p-3 border-b border-default last:border-b-0 cursor-pointer hover:bg-elevated/50 transition-colors"
|
||||
:class="{ 'bg-elevated/25': !notification.read }"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon
|
||||
:name="notificationIcon(notification.type)"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
:class="notification.read ? 'text-dimmed' : 'text-primary'"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ notification.title }}</p>
|
||||
<p
|
||||
v-if="notification.body"
|
||||
class="text-xs text-dimmed mt-0.5 line-clamp-2"
|
||||
>
|
||||
{{ notification.body }}
|
||||
</p>
|
||||
<p class="text-xs text-dimmed mt-1">
|
||||
{{ formatDate(notification.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notificationsStore.hasMore && notificationsStore.notifications.length > 0"
|
||||
class="p-2 border-t border-default"
|
||||
class="p-2"
|
||||
>
|
||||
<UButton
|
||||
:label="t('notifications.loadMore')"
|
||||
@@ -86,7 +94,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end w-full">
|
||||
<UButton
|
||||
:label="t('common.close')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -94,8 +113,10 @@ import { ref, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Notification } from 'taskview-api'
|
||||
import { NotificationType } from 'taskview-api'
|
||||
import { useNotificationsStore } from '@/stores/notifications.store'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
import { ALL_TASKS_LIST_ID } from 'taskview-api'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
@@ -103,8 +124,21 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const notificationsStore = useNotificationsStore()
|
||||
const userStore = useUserStore()
|
||||
const { isMobile } = useTaskView()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const notificationIconMap: Partial<Record<NotificationType, string>> = {
|
||||
[NotificationType.DEADLINE]: 'i-lucide-clock',
|
||||
[NotificationType.ASSIGN]: 'i-lucide-user-plus',
|
||||
[NotificationType.MENTION]: 'i-lucide-at-sign',
|
||||
[NotificationType.COMMENT]: 'i-lucide-message-circle',
|
||||
[NotificationType.STATUS_CHANGE]: 'i-lucide-arrow-right-left',
|
||||
}
|
||||
|
||||
function notificationIcon(type: NotificationType) {
|
||||
return notificationIconMap[type] || 'i-lucide-bell'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateStr), { addSuffix: true })
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
{{ t('account.title') }}
|
||||
</h1>
|
||||
|
||||
<NotificationSettings />
|
||||
|
||||
<UPageCard class="w-full">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
@@ -33,6 +35,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import DeleteAccountButton from './parts/DeleteAccountButton.vue'
|
||||
import DeleteAccountCodeModal from './parts/DeleteAccountCodeModal.vue'
|
||||
import NotificationSettings from './parts/NotificationSettings.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<UPageCard class="w-full">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">{{ t('notifications.settings.title') }}</h2>
|
||||
<p class="text-sm text-dimmed mt-1">{{ t('notifications.settings.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-4">
|
||||
<UIcon name="i-lucide-loader-2" class="size-5 animate-spin" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-0 divide-y divide-default">
|
||||
<div
|
||||
v-for="item in notificationTypes"
|
||||
:key="item.type"
|
||||
class="py-4 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ item.label }}</p>
|
||||
<p class="text-xs text-dimmed">{{ item.description }}</p>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<label
|
||||
v-for="channel in channels"
|
||||
:key="channel.key"
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="isEnabled(item.type, channel.key)"
|
||||
@update:model-value="toggle(item.type, channel.key, $event as boolean)"
|
||||
/>
|
||||
<span class="text-sm">{{ channel.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UPageCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NotificationType, NotificationChannel } from 'taskview-api'
|
||||
import type { NotificationPreferencesSettings } from 'taskview-api'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const settings = ref<NotificationPreferencesSettings>({})
|
||||
|
||||
const notificationTypes = computed(() => [
|
||||
{
|
||||
type: NotificationType.DEADLINE,
|
||||
label: t('notifications.settings.types.deadline'),
|
||||
description: t('notifications.settings.types.deadlineDescription'),
|
||||
},
|
||||
{
|
||||
type: NotificationType.ASSIGN,
|
||||
label: t('notifications.settings.types.assign'),
|
||||
description: t('notifications.settings.types.assignDescription'),
|
||||
},
|
||||
])
|
||||
|
||||
const channels = computed(() => [
|
||||
{ key: NotificationChannel.PUSH, label: t('notifications.settings.push') },
|
||||
{ key: NotificationChannel.WEBSOCKET, label: t('notifications.settings.websocket') },
|
||||
])
|
||||
|
||||
function isEnabled(type: NotificationType, channel: NotificationChannel): boolean {
|
||||
return settings.value.global?.[type]?.channels?.[channel] !== false
|
||||
}
|
||||
|
||||
function toggle(type: NotificationType, channel: NotificationChannel, enabled: boolean) {
|
||||
if (!settings.value.global) settings.value.global = {}
|
||||
if (!settings.value.global[type]) settings.value.global[type] = {}
|
||||
if (!settings.value.global[type]!.channels) settings.value.global[type]!.channels = {}
|
||||
settings.value.global[type]!.channels![channel] = enabled
|
||||
save()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const result = await $tvApi.notifications.savePreferences(settings.value)
|
||||
if (result) {
|
||||
toast.add({ title: t('notifications.settings.saved'), color: 'success' })
|
||||
}
|
||||
} catch {
|
||||
toast.add({ title: t('common.error'), color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await $tvApi.notifications.getPreferences()
|
||||
if (result) {
|
||||
settings.value = result.settings
|
||||
}
|
||||
} catch {
|
||||
toast.add({ title: t('common.error'), color: 'error' })
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
@@ -38,56 +38,62 @@
|
||||
@click="showAddDialog = true"
|
||||
/>
|
||||
|
||||
<UModal v-model:open="showAddDialog">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('server.addNewServer') }}
|
||||
</h3>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
@click="showAddDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<UModal
|
||||
v-model:open="showAddDialog"
|
||||
:fullscreen="isMobile"
|
||||
:ui="{
|
||||
content: 'sm:max-w-md sm:rounded-lg sm:m-auto w-full',
|
||||
footer: 'p-4!',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('server.addNewServer') }}
|
||||
</h3>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
@click="showAddDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UFormField :label="t('server.serverUrl')">
|
||||
<UInput
|
||||
v-model="newServerUrl"
|
||||
placeholder="https://api.example.com"
|
||||
class="w-full"
|
||||
@keyup.enter="handleAddServer"
|
||||
/>
|
||||
</UFormField>
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('server.serverUrlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<UFormField :label="t('server.serverUrl')">
|
||||
<UInput
|
||||
v-model="newServerUrl"
|
||||
placeholder="https://api.example.com"
|
||||
class="w-full"
|
||||
autofocus
|
||||
@keyup.enter="handleAddServer"
|
||||
/>
|
||||
</UFormField>
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('server.serverUrlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="showAddDialog = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
:disabled="!isValidUrl"
|
||||
variant="outline"
|
||||
@click="handleAddServer"
|
||||
>
|
||||
{{ t('common.add') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="showAddDialog = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
:disabled="!isValidUrl"
|
||||
variant="outline"
|
||||
@click="handleAddServer"
|
||||
>
|
||||
{{ t('common.add') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
@@ -96,9 +102,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core'
|
||||
import { useAdditionalServer } from '@/composables/useAdditionalServer'
|
||||
|
||||
const { t } = useI18n()
|
||||
const bp = useBreakpoints(breakpointsTailwind)
|
||||
const isMobile = bp.smaller('lg')
|
||||
|
||||
const mainServer = ref('')
|
||||
const allServers = ref<string[]>([])
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<TvListFilter v-model="listIds" />
|
||||
<TvUserFilter v-model="assigneeIds" />
|
||||
<UButton
|
||||
v-if="hasActiveFilters"
|
||||
icon="i-lucide-x"
|
||||
:label="showResetLabel ? t('filters.reset') : undefined"
|
||||
class="shrink-0"
|
||||
:class="{ '[&>span:last-child]:hidden lg:[&>span:last-child]:inline': showResetLabel }"
|
||||
variant="soft"
|
||||
color="error"
|
||||
size="sm"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TvListFilter from './TvListFilter.vue'
|
||||
import TvUserFilter from './TvUserFilter.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
showResetLabel?: boolean
|
||||
}>(), {
|
||||
showResetLabel: false,
|
||||
})
|
||||
|
||||
const listIds = defineModel<number[]>('listIds', { default: () => [] })
|
||||
const assigneeIds = defineModel<number[]>('assigneeIds', { default: () => [] })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasActiveFilters = computed(() => listIds.value.length > 0 || assigneeIds.value.length > 0)
|
||||
|
||||
const resetFilters = () => {
|
||||
listIds.value = []
|
||||
assigneeIds.value = []
|
||||
}
|
||||
|
||||
defineExpose({ hasActiveFilters })
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<USelectMenu
|
||||
v-model="selected"
|
||||
:items="options"
|
||||
multiple
|
||||
value-key="value"
|
||||
:placeholder="t('tasks.allTasks')"
|
||||
:search-input="false"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
class="min-w-40 max-w-56 shrink-0 [&>button]:truncate"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon
|
||||
:name="model.length === 0 ? 'i-lucide-layers' : 'i-lucide-list'"
|
||||
class="size-4"
|
||||
/>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGoalListsStore } from '@/stores/goal-lists.store'
|
||||
|
||||
const ALL_VALUE = -1
|
||||
|
||||
const model = defineModel<number[]>({ default: () => [] })
|
||||
|
||||
const { t } = useI18n()
|
||||
const goalListsStore = useGoalListsStore()
|
||||
|
||||
const options = computed(() => [
|
||||
{ label: t('tasks.allTasks'), value: ALL_VALUE },
|
||||
...goalListsStore.lists.map((list) => ({
|
||||
label: list.name,
|
||||
value: list.id,
|
||||
})),
|
||||
])
|
||||
|
||||
const hadAll = computed(() => model.value.length === 0)
|
||||
|
||||
const selected = computed({
|
||||
get() {
|
||||
return model.value.length === 0 ? [ALL_VALUE] : model.value
|
||||
},
|
||||
set(val: number[]) {
|
||||
const allJustSelected = val.includes(ALL_VALUE) && !hadAll.value
|
||||
if (allJustSelected || val.length === 0) {
|
||||
model.value = []
|
||||
} else {
|
||||
model.value = val.filter((v) => v !== ALL_VALUE)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<USelectMenu
|
||||
v-model="selected"
|
||||
:items="options"
|
||||
multiple
|
||||
value-key="value"
|
||||
:placeholder="t('filters.allUsers')"
|
||||
:search-input="false"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
class="min-w-40 max-w-56 shrink-0 [&>button]:truncate"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon
|
||||
:name="model.length === 0 ? 'i-lucide-users' : 'i-lucide-user'"
|
||||
class="size-4"
|
||||
/>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useCollaborationStore } from '@/stores/collaboration.store'
|
||||
|
||||
const ALL_VALUE = -1
|
||||
|
||||
const model = defineModel<number[]>({ default: () => [] })
|
||||
|
||||
const { t } = useI18n()
|
||||
const collaborationStore = useCollaborationStore()
|
||||
|
||||
const options = computed(() => [
|
||||
{ label: t('filters.allUsers'), value: ALL_VALUE },
|
||||
...collaborationStore.users.map((user) => ({
|
||||
label: user.email,
|
||||
value: user.id,
|
||||
})),
|
||||
])
|
||||
|
||||
const hadAll = computed(() => model.value.length === 0)
|
||||
|
||||
const selected = computed({
|
||||
get() {
|
||||
return model.value.length === 0 ? [ALL_VALUE] : model.value
|
||||
},
|
||||
set(val: number[]) {
|
||||
const allJustSelected = val.includes(ALL_VALUE) && !hadAll.value
|
||||
if (allJustSelected || val.length === 0) {
|
||||
model.value = []
|
||||
} else {
|
||||
model.value = val.filter((v) => v !== ALL_VALUE)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -98,6 +98,28 @@ import AddNewTaskToGraph from './AddNewTaskToGraph.vue'
|
||||
import { useLayout } from './composables/useLayout'
|
||||
import TaskNode from './TaskNode.vue'
|
||||
|
||||
const listIds = defineModel<number[]>('listIds', { default: () => [] })
|
||||
const assigneeIds = defineModel<number[]>('assigneeIds', { default: () => [] })
|
||||
|
||||
const applyFilters = () => {
|
||||
let filtered = [...store.allNodes]
|
||||
if (listIds.value.length > 0) {
|
||||
filtered = filtered.filter((n) => listIds.value.includes(n.data.task.goalListId))
|
||||
}
|
||||
if (assigneeIds.value.length > 0) {
|
||||
filtered = filtered.filter((n) =>
|
||||
n.data.task.assignedUsers?.some((id: number) => assigneeIds.value.includes(id))
|
||||
)
|
||||
}
|
||||
const nodeIds = new Set(filtered.map((n) => n.id))
|
||||
store.nodes = filtered
|
||||
store.edges = store.allEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
|
||||
setTimeout(() => layoutGraph(layoutDirection.value), 50)
|
||||
}
|
||||
|
||||
watch(listIds, applyFilters, { deep: true })
|
||||
watch(assigneeIds, applyFilters, { deep: true })
|
||||
|
||||
const {
|
||||
fitView,
|
||||
onConnect,
|
||||
|
||||
@@ -41,7 +41,7 @@ const deleteOpen = ref(false)
|
||||
const ui = {
|
||||
itemLeadingIcon: 'size-4',
|
||||
item: 'items-center',
|
||||
}
|
||||
} as DropdownMenuItem['ui']
|
||||
const menuItems = computed<DropdownMenuItem[][]>(() => [
|
||||
[
|
||||
{
|
||||
|
||||
@@ -96,6 +96,17 @@
|
||||
:to="{ name: 'integrations', params: { projectId: selectedProject?.id } }"
|
||||
@click="contextMenu?.close()"
|
||||
/>
|
||||
<!-- Webhooks -->
|
||||
<UButton
|
||||
v-if="canViewIntegrations"
|
||||
:label="t('contextMenu.webhooks')"
|
||||
icon="i-lucide-webhook"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
class="w-full justify-start"
|
||||
:to="{ name: 'webhooks', params: { projectId: selectedProject?.id } }"
|
||||
@click="contextMenu?.close()"
|
||||
/>
|
||||
|
||||
<USeparator class="my-1" />
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<NoteEditor
|
||||
:key="task.id"
|
||||
:content="task.note || ''"
|
||||
:content-type="task.sourceUrl ? 'markdown' : 'html'"
|
||||
:placeholder="t('tasks.addNote')"
|
||||
@save="updateNote"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
v-if="canViewTaskNote"
|
||||
#default="{ editor }"
|
||||
v-model="initialContent"
|
||||
:content-type="contentType"
|
||||
:placeholder="placeholder"
|
||||
:extensions="extensions"
|
||||
:editable="canEditTaskNote"
|
||||
@@ -39,6 +40,7 @@ const extensions = [
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
contentType?: 'html' | 'markdown'
|
||||
placeholder?: string
|
||||
debounce?: number
|
||||
}>()
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<UInputTime
|
||||
v-if="showStartTime"
|
||||
v-model="startTimeModel"
|
||||
hour-format="24"
|
||||
:hour-cycle="24"
|
||||
/>
|
||||
<UButton
|
||||
v-if="hasStartDate"
|
||||
@@ -79,7 +79,7 @@
|
||||
<UInputTime
|
||||
v-if="showEndTime"
|
||||
v-model="endTimeModel"
|
||||
hour-format="24"
|
||||
:hour-cycle="24"
|
||||
/>
|
||||
<UButton
|
||||
v-if="hasEndDate"
|
||||
@@ -219,28 +219,32 @@ function useDeferredLoading(source: typeof loadingStart, deferred: typeof deferr
|
||||
})
|
||||
}
|
||||
|
||||
// Parse date string (YYYY-MM-DD) to CalendarDate
|
||||
function parseDateString(dateStr: string | null | undefined): CalendarDate | undefined {
|
||||
if (!dateStr) return undefined
|
||||
const [year, month, day] = dateStr.split('-').map(Number)
|
||||
return new CalendarDate(year, month, day)
|
||||
}
|
||||
// Parse UTC date + optional time from server into local CalendarDate + Time
|
||||
function parseFromServer(dateStr: string | null | undefined, timeStr: string | null | undefined): { date: CalendarDate | undefined; time: Time | undefined } {
|
||||
if (!dateStr) return { date: undefined, time: undefined }
|
||||
|
||||
// Parse time string (HH:mm:ss+TZ) to Time
|
||||
function parseTimeString(timeStr: string | null | undefined): Time | undefined {
|
||||
if (!timeStr) return undefined
|
||||
const match = timeStr.match(/^(\d{2}):(\d{2})/)
|
||||
if (match) {
|
||||
return new Time(parseInt(match[1], 10), parseInt(match[2], 10))
|
||||
if (timeStr) {
|
||||
const match = timeStr.match(/^(\d{2}):(\d{2})/)
|
||||
if (match) {
|
||||
const utc = new Date(`${dateStr}T${match[1]}:${match[2]}:00Z`)
|
||||
return {
|
||||
date: new CalendarDate(utc.getFullYear(), utc.getMonth() + 1, utc.getDate()),
|
||||
time: new Time(utc.getHours(), utc.getMinutes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
||||
const [year, month, day] = dateStr.split('-').map(Number)
|
||||
return { date: new CalendarDate(year, month, day), time: undefined }
|
||||
}
|
||||
|
||||
// Use shallowRef to avoid Vue stripping private fields from class instances
|
||||
const startDateModel = shallowRef<CalendarDate | undefined>(parseDateString(props.task.startDate))
|
||||
const endDateModel = shallowRef<CalendarDate | undefined>(parseDateString(props.task.endDate))
|
||||
const startTimeModel = shallowRef<Time | undefined>(parseTimeString(props.task.startTime))
|
||||
const endTimeModel = shallowRef<Time | undefined>(parseTimeString(props.task.endTime))
|
||||
const initStart = parseFromServer(props.task.startDate, props.task.startTime)
|
||||
const initEnd = parseFromServer(props.task.endDate, props.task.endTime)
|
||||
const startDateModel = shallowRef<CalendarDate | undefined>(initStart.date)
|
||||
const endDateModel = shallowRef<CalendarDate | undefined>(initEnd.date)
|
||||
const startTimeModel = shallowRef<Time | undefined>(initStart.time)
|
||||
const endTimeModel = shallowRef<Time | undefined>(initEnd.time)
|
||||
const showStartTime = ref(!!props.task.startTime)
|
||||
const showEndTime = ref(!!props.task.endTime)
|
||||
const startPopoverOpen = ref(false)
|
||||
@@ -250,12 +254,30 @@ const endPopoverOpen = ref(false)
|
||||
// so the watchers below don't fire extra save requests
|
||||
let syncing = false
|
||||
|
||||
// user unchecked "select time" — reset the time value
|
||||
// user toggled "select time" checkbox
|
||||
watch(showStartTime, (val) => {
|
||||
if (!val) startTimeModel.value = undefined
|
||||
if (!val) {
|
||||
startTimeModel.value = undefined
|
||||
} else if (!startDateModel.value) {
|
||||
syncing = true
|
||||
const now = getTodayCalendarDate()
|
||||
startDateModel.value = endDateModel.value && endDateModel.value.compare(now) < 0
|
||||
? endDateModel.value
|
||||
: now
|
||||
nextTick(() => { syncing = false })
|
||||
}
|
||||
})
|
||||
watch(showEndTime, (val) => {
|
||||
if (!val) endTimeModel.value = undefined
|
||||
if (!val) {
|
||||
endTimeModel.value = undefined
|
||||
} else if (!endDateModel.value) {
|
||||
syncing = true
|
||||
const now = getTodayCalendarDate()
|
||||
endDateModel.value = startDateModel.value && startDateModel.value.compare(now) > 0
|
||||
? startDateModel.value
|
||||
: now
|
||||
nextTick(() => { syncing = false })
|
||||
}
|
||||
})
|
||||
|
||||
// these two watchers exist because UCalendar updates the model via v-model
|
||||
@@ -305,10 +327,12 @@ watch(
|
||||
() => props.task,
|
||||
(task) => {
|
||||
syncing = true
|
||||
startDateModel.value = parseDateString(task.startDate)
|
||||
endDateModel.value = parseDateString(task.endDate)
|
||||
startTimeModel.value = parseTimeString(task.startTime)
|
||||
endTimeModel.value = parseTimeString(task.endTime)
|
||||
const start = parseFromServer(task.startDate, task.startTime)
|
||||
const end = parseFromServer(task.endDate, task.endTime)
|
||||
startDateModel.value = start.date
|
||||
endDateModel.value = end.date
|
||||
startTimeModel.value = start.time
|
||||
endTimeModel.value = end.time
|
||||
showStartTime.value = !!task.startTime
|
||||
showEndTime.value = !!task.endTime
|
||||
nextTick(() => { syncing = false })
|
||||
@@ -346,47 +370,33 @@ function formatTimeForDisplay(time: Time): string {
|
||||
return `${String(time.hour).padStart(2, '0')}:${String(time.minute).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getTimeZone(): string {
|
||||
const date = new Date()
|
||||
const timezoneOffsetInMinutes = date.getTimezoneOffset()
|
||||
const offsetHours = Math.floor(Math.abs(timezoneOffsetInMinutes) / 60)
|
||||
const offsetMinutes = Math.abs(timezoneOffsetInMinutes) % 60
|
||||
const offsetSign = timezoneOffsetInMinutes <= 0 ? '+' : '-'
|
||||
return `${offsetSign}${String(offsetHours).padStart(2, '0')}:${String(offsetMinutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDateForApi(calendarDate: CalendarDate): string {
|
||||
return calendarDate.toString() // Returns YYYY-MM-DD
|
||||
}
|
||||
|
||||
function formatTimeForApi(time: Time): string {
|
||||
const timeZone = getTimeZone()
|
||||
const timeStr = `${String(time.hour).padStart(2, '0')}:${String(time.minute).padStart(2, '0')}:00`
|
||||
return `${timeStr}${timeZone}`
|
||||
// Convert local date+time to UTC for API, returns { date, time } strings
|
||||
// When time is null, date is sent as-is (calendar date, no timezone shift)
|
||||
function toApiDateTime(date: CalendarDate, time?: Time): { date: string; time: string | null } {
|
||||
if (!time) return { date: date.toString(), time: null }
|
||||
const local = new Date(date.year, date.month - 1, date.day, time.hour, time.minute, 0)
|
||||
const y = local.getUTCFullYear()
|
||||
const m = String(local.getUTCMonth() + 1).padStart(2, '0')
|
||||
const d = String(local.getUTCDate()).padStart(2, '0')
|
||||
const h = String(local.getUTCHours()).padStart(2, '0')
|
||||
const min = String(local.getUTCMinutes()).padStart(2, '0')
|
||||
return { date: `${y}-${m}-${d}`, time: `${h}:${min}:00` }
|
||||
}
|
||||
|
||||
// Save functions
|
||||
async function saveStartDateTime() {
|
||||
if (!startDateModel.value) return
|
||||
|
||||
loadingStart.value = true
|
||||
await tasksStore.saveDateForTask({
|
||||
id: props.task.id,
|
||||
startDate: formatDateForApi(startDateModel.value as CalendarDate),
|
||||
startTime: startTimeModel.value ? formatTimeForApi(startTimeModel.value as Time) : null,
|
||||
})
|
||||
const { date, time } = toApiDateTime(startDateModel.value, startTimeModel.value)
|
||||
await tasksStore.saveDateForTask({ id: props.task.id, startDate: date, startTime: time })
|
||||
loadingStart.value = false
|
||||
}
|
||||
|
||||
async function saveEndDateTime() {
|
||||
if (!endDateModel.value) return
|
||||
|
||||
loadingEnd.value = true
|
||||
await tasksStore.saveDateForTask({
|
||||
id: props.task.id,
|
||||
endDate: formatDateForApi(endDateModel.value as CalendarDate),
|
||||
endTime: endTimeModel.value ? formatTimeForApi(endTimeModel.value as Time) : null,
|
||||
})
|
||||
const { date, time } = toApiDateTime(endDateModel.value, endTimeModel.value)
|
||||
await tasksStore.saveDateForTask({ id: props.task.id, endDate: date, endTime: time })
|
||||
loadingEnd.value = false
|
||||
}
|
||||
|
||||
@@ -462,8 +472,8 @@ async function setQuickDate(type: 'today' | 'tomorrow' | 'yesterday') {
|
||||
})
|
||||
|
||||
syncing = true
|
||||
startDateModel.value = parseDateString(date)
|
||||
endDateModel.value = parseDateString(date)
|
||||
startDateModel.value = parseFromServer(date, null).date
|
||||
endDateModel.value = parseFromServer(date, null).date
|
||||
nextTick(() => { syncing = false })
|
||||
|
||||
activeQuickAction.value = null
|
||||
@@ -499,8 +509,8 @@ async function setQuickRange(type: 'week' | 'month') {
|
||||
})
|
||||
|
||||
syncing = true
|
||||
startDateModel.value = parseDateString(start)
|
||||
endDateModel.value = parseDateString(end)
|
||||
startDateModel.value = parseFromServer(start, null).date
|
||||
endDateModel.value = parseFromServer(end, null).date
|
||||
nextTick(() => { syncing = false })
|
||||
|
||||
activeQuickAction.value = null
|
||||
|
||||
@@ -190,15 +190,29 @@ const listName = computed(() => {
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.task.endDate) return ''
|
||||
return formatDate(new Date(props.task.endDate), 'DD.MMM.YYYY')
|
||||
if (props.task.endTime) {
|
||||
const match = props.task.endTime.match(/^(\d{2}):(\d{2})/)
|
||||
if (match) {
|
||||
const utc = new Date(`${props.task.endDate}T${match[1]}:${match[2]}:00Z`)
|
||||
return formatDate(utc, 'DD.MMM.YYYY HH:mm')
|
||||
}
|
||||
}
|
||||
const [y, m, d] = props.task.endDate.split('-').map(Number)
|
||||
return formatDate(new Date(y, m - 1, d), 'DD.MMM.YYYY')
|
||||
})
|
||||
|
||||
const isOverdue = computed(() => {
|
||||
if (!props.task.endDate || localComplete.value) return false
|
||||
const end = new Date(props.task.endDate)
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
return end < now
|
||||
if (props.task.endTime) {
|
||||
const match = props.task.endTime.match(/^(\d{2}):(\d{2})/)
|
||||
if (match) {
|
||||
const utc = new Date(`${props.task.endDate}T${match[1]}:${match[2]}:00Z`)
|
||||
return utc < new Date()
|
||||
}
|
||||
}
|
||||
const [y, m, d] = props.task.endDate.split('-').map(Number)
|
||||
const end = new Date(y, m - 1, d, 23, 59, 59)
|
||||
return end < new Date()
|
||||
})
|
||||
|
||||
const assigneeEmails = computed(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<USelectMenu
|
||||
v-if="canViewKanban"
|
||||
:search-input="false"
|
||||
:disabled="!canManageKanban || true"
|
||||
:disabled="!canManageKanban"
|
||||
:model-value="selectedItem"
|
||||
:items="statusItems"
|
||||
:placeholder="t('tasks.selectStatus')"
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div
|
||||
v-if="!hasGoalSelected"
|
||||
class="flex flex-col items-center justify-center h-64 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-webhook"
|
||||
class="size-12 mb-4"
|
||||
/>
|
||||
<p>{{ t('webhooks.selectProject') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ projectName }}
|
||||
</h2>
|
||||
<UButton
|
||||
v-if="canManageIntegrations"
|
||||
:label="t('webhooks.add')"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
@click="isAddModalOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center h-32"
|
||||
>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="webhooksStore.webhooks.length === 0"
|
||||
class="flex flex-col items-center justify-center h-64 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-webhook"
|
||||
class="size-12 mb-4"
|
||||
/>
|
||||
<p>{{ t('webhooks.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<WebhookItem
|
||||
v-for="item in webhooksStore.webhooks"
|
||||
:key="item.id"
|
||||
:webhook="item"
|
||||
:can-manage="canManageIntegrations"
|
||||
@toggle="handleToggle"
|
||||
@delete="handleDelete"
|
||||
@show-deliveries="openDeliveries"
|
||||
@edit="openEdit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AddWebhookModal
|
||||
v-model:open="isAddModalOpen"
|
||||
:goal-id="projectId"
|
||||
@created="fetchData"
|
||||
/>
|
||||
|
||||
<EditWebhookModal
|
||||
v-model:open="isEditModalOpen"
|
||||
:webhook="selectedWebhook"
|
||||
@saved="fetchData"
|
||||
/>
|
||||
|
||||
<WebhookDeliveries
|
||||
v-model:open="isDeliveriesOpen"
|
||||
:webhook-id="selectedWebhookId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { WebhookItem as WebhookItemType } from 'taskview-api'
|
||||
import { useWebhooksStore } from '@/stores/webhooks.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
|
||||
import { useGoalPermissions } from '@/composables/useGoalPermissions'
|
||||
import WebhookItem from './parts/WebhookItem.vue'
|
||||
import AddWebhookModal from './parts/AddWebhookModal.vue'
|
||||
import EditWebhookModal from './parts/EditWebhookModal.vue'
|
||||
import WebhookDeliveries from './parts/WebhookDeliveries.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { projectId } = useAppRouteInfo()
|
||||
const goalsStore = useGoalsStore()
|
||||
const webhooksStore = useWebhooksStore()
|
||||
const { canManageIntegrations } = useGoalPermissions()
|
||||
|
||||
const loading = ref(false)
|
||||
const isAddModalOpen = ref(false)
|
||||
const isEditModalOpen = ref(false)
|
||||
const isDeliveriesOpen = ref(false)
|
||||
const selectedWebhookId = ref(0)
|
||||
const selectedWebhook = ref<WebhookItemType | null>(null)
|
||||
|
||||
const hasGoalSelected = computed(() => projectId.value > 0)
|
||||
const projectName = computed(() => goalsStore.goalMap.get(projectId.value)?.name ?? '')
|
||||
|
||||
async function fetchData() {
|
||||
if (!hasGoalSelected.value) return
|
||||
loading.value = true
|
||||
await webhooksStore.fetchWebhooks(projectId.value)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(projectId, fetchData, { immediate: true })
|
||||
|
||||
async function handleToggle(id: number, isActive: boolean) {
|
||||
await webhooksStore.updateWebhook({ id, isActive })
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await webhooksStore.deleteWebhook(id)
|
||||
}
|
||||
|
||||
function openDeliveries(webhookId: number) {
|
||||
selectedWebhookId.value = webhookId
|
||||
isDeliveriesOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(webhook: WebhookItemType) {
|
||||
selectedWebhook.value = webhook
|
||||
isEditModalOpen.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen" :fullscreen="isMobile">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('webhooks.add') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UFormField :label="t('webhooks.url')">
|
||||
<UInput
|
||||
v-model="url"
|
||||
:placeholder="t('webhooks.urlPlaceholder')"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField :label="t('webhooks.events')">
|
||||
<USelectMenu
|
||||
v-model="selectedEvents"
|
||||
:items="eventOptions"
|
||||
multiple
|
||||
value-key="value"
|
||||
:placeholder="t('webhooks.selectEvents')"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.add')"
|
||||
color="primary"
|
||||
:disabled="!url || selectedEvents.length === 0"
|
||||
:loading="saving"
|
||||
@click="handleCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="showSecret" :fullscreen="isMobile">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('webhooks.secretTitle') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-sm text-muted mb-3">
|
||||
{{ t('webhooks.secretDescription') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-2 p-3 bg-elevated rounded-lg">
|
||||
<span class="font-mono text-sm break-all">{{ createdSecret }}</span>
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
@click="copySecret(createdSecret)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end">
|
||||
<UButton
|
||||
:label="t('common.done')"
|
||||
color="primary"
|
||||
@click="showSecret = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { WEBHOOK_EVENTS } from 'taskview-api'
|
||||
import { useWebhooksStore } from '@/stores/webhooks.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const props = defineProps<{
|
||||
goalId: number
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const webhooksStore = useWebhooksStore()
|
||||
const { copy } = useClipboard()
|
||||
|
||||
const url = ref('')
|
||||
const selectedEvents = ref<string[]>([])
|
||||
const saving = ref(false)
|
||||
const showSecret = ref(false)
|
||||
const createdSecret = ref('')
|
||||
|
||||
const eventOptions = WEBHOOK_EVENTS.map((e) => ({
|
||||
label: e,
|
||||
value: e,
|
||||
}))
|
||||
|
||||
function copySecret(secret: string) {
|
||||
copy(secret)
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await webhooksStore.createWebhook({
|
||||
goalId: props.goalId,
|
||||
url: url.value,
|
||||
events: selectedEvents.value,
|
||||
})
|
||||
|
||||
if (result) {
|
||||
isOpen.value = false
|
||||
createdSecret.value = result.secret
|
||||
showSecret.value = true
|
||||
url.value = ''
|
||||
selectedEvents.value = []
|
||||
emit('created')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen" :fullscreen="isMobile">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('webhooks.edit') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UFormField :label="t('webhooks.url')">
|
||||
<UInput
|
||||
v-model="url"
|
||||
:placeholder="t('webhooks.urlPlaceholder')"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField :label="t('webhooks.events')">
|
||||
<USelectMenu
|
||||
v-model="selectedEvents"
|
||||
:items="eventOptions"
|
||||
multiple
|
||||
value-key="value"
|
||||
:placeholder="t('webhooks.selectEvents')"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.save')"
|
||||
color="primary"
|
||||
:disabled="!url || selectedEvents.length === 0"
|
||||
:loading="saving"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { WEBHOOK_EVENTS } from 'taskview-api'
|
||||
import type { WebhookItem } from 'taskview-api'
|
||||
import { useWebhooksStore } from '@/stores/webhooks.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const props = defineProps<{
|
||||
webhook: WebhookItem | null
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const webhooksStore = useWebhooksStore()
|
||||
|
||||
const url = ref('')
|
||||
const selectedEvents = ref<string[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
const eventOptions = WEBHOOK_EVENTS.map((e) => ({
|
||||
label: e,
|
||||
value: e,
|
||||
}))
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open && props.webhook) {
|
||||
url.value = props.webhook.url
|
||||
selectedEvents.value = [...props.webhook.events]
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.webhook) return
|
||||
saving.value = true
|
||||
try {
|
||||
await webhooksStore.updateWebhook({
|
||||
id: props.webhook.id,
|
||||
url: url.value,
|
||||
events: selectedEvents.value,
|
||||
})
|
||||
isOpen.value = false
|
||||
emit('saved')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen" :fullscreen="isMobile" :ui="{ content: 'sm:max-w-3xl' }">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('webhooks.payload') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<pre class="p-3 bg-elevated rounded-lg text-xs whitespace-pre-wrap break-all">{{ formattedPayload }}</pre>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
:label="t('webhooks.copy')"
|
||||
variant="ghost"
|
||||
:ui="{ leadingIcon: 'size-4!' }"
|
||||
@click="copy(formattedPayload)"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('common.close')"
|
||||
variant="ghost"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const props = defineProps<{
|
||||
payload: unknown
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const { copy } = useClipboard()
|
||||
|
||||
const formattedPayload = computed(() => JSON.stringify(props.payload, null, 2))
|
||||
</script>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen" :fullscreen="isMobile" :ui="{ content: 'sm:max-w-3xl', body: 'p-0!' }">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('webhooks.deliveries') }}
|
||||
</h3>
|
||||
<USelectMenu
|
||||
v-model="statusFilter"
|
||||
:items="statusOptions"
|
||||
value-key="value"
|
||||
:placeholder="t('webhooks.allStatuses')"
|
||||
:search-input="false"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
class="w-36 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
v-if="webhooksStore.deliveriesLoading && webhooksStore.deliveries.length === 0"
|
||||
class="flex items-center justify-center h-32"
|
||||
>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="webhooksStore.deliveries.length === 0"
|
||||
class="flex flex-col items-center justify-center h-32 text-muted"
|
||||
>
|
||||
<p>{{ t('webhooks.noDeliveries') }}</p>
|
||||
</div>
|
||||
<div v-else ref="scrollContainer">
|
||||
<UTable
|
||||
:data="webhooksStore.deliveries"
|
||||
:columns="columns"
|
||||
sticky
|
||||
:loading="webhooksStore.deliveriesLoading && webhooksStore.deliveries.length > 0"
|
||||
:ui="{ thead: 'sticky top-0 z-10' }"
|
||||
class="max-h-80"
|
||||
>
|
||||
<template #event-cell="{ row }">
|
||||
<span class="font-mono text-xs">{{ row.original.event }}</span>
|
||||
</template>
|
||||
<template #status-cell="{ row }">
|
||||
<UBadge
|
||||
:color="statusColor(row.original.status)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ row.original.status }}
|
||||
</UBadge>
|
||||
</template>
|
||||
<template #responseCode-cell="{ row }">
|
||||
{{ row.original.responseCode ?? '—' }}
|
||||
</template>
|
||||
<template #createdAt-cell="{ row }">
|
||||
<span class="text-muted whitespace-nowrap">{{ formatTime(row.original.createdAt) }}</span>
|
||||
</template>
|
||||
<template #actions-cell="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<UButton
|
||||
icon="i-lucide-eye"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:title="t('webhooks.viewPayload')"
|
||||
@click="openPayload(row.original)"
|
||||
/>
|
||||
<UButton
|
||||
v-if="row.original.status === 'failed'"
|
||||
icon="i-lucide-refresh-cw"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:loading="retrying === row.original.id"
|
||||
@click="retryDelivery(row.original)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end">
|
||||
<UButton
|
||||
:label="t('common.close')"
|
||||
variant="ghost"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<PayloadViewer
|
||||
v-model:open="showPayload"
|
||||
:payload="selectedPayload"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useInfiniteScroll } from '@vueuse/core'
|
||||
import type { TableColumn } from '@nuxt/ui'
|
||||
import { useWebhooksStore } from '@/stores/webhooks.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
import PayloadViewer from './PayloadViewer.vue'
|
||||
import type { WebhookDeliveryItem } from 'taskview-api'
|
||||
|
||||
const props = defineProps<{
|
||||
webhookId: number
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const webhooksStore = useWebhooksStore()
|
||||
|
||||
const retrying = ref<number | null>(null)
|
||||
const showPayload = ref(false)
|
||||
const selectedPayload = ref<unknown>(null)
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const statusFilter = ref('all')
|
||||
const hasMore = ref(true)
|
||||
|
||||
const ALL_VALUE = 'all'
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t('webhooks.allStatuses'), value: ALL_VALUE },
|
||||
{ label: 'success', value: 'success' },
|
||||
{ label: 'failed', value: 'failed' },
|
||||
{ label: 'pending', value: 'pending' },
|
||||
]
|
||||
|
||||
const columns: TableColumn<WebhookDeliveryItem>[] = [
|
||||
{ accessorKey: 'event', header: t('webhooks.event') },
|
||||
{ accessorKey: 'status', header: t('webhooks.status') },
|
||||
{ accessorKey: 'responseCode', header: t('webhooks.httpCode') },
|
||||
{ accessorKey: 'attempts', header: t('webhooks.attempts') },
|
||||
{ accessorKey: 'createdAt', header: t('webhooks.time') },
|
||||
{ id: 'actions', header: '' },
|
||||
]
|
||||
|
||||
const currentStatus = () => statusFilter.value === ALL_VALUE ? undefined : statusFilter.value
|
||||
|
||||
async function loadInitial() {
|
||||
hasMore.value = true
|
||||
await webhooksStore.fetchDeliveries(props.webhookId, { status: currentStatus() })
|
||||
hasMore.value = webhooksStore.deliveries.length >= 20
|
||||
await nextTick()
|
||||
setupInfiniteScroll()
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!hasMore.value || webhooksStore.deliveriesLoading) return
|
||||
const last = webhooksStore.deliveries[webhooksStore.deliveries.length - 1]
|
||||
if (!last) return
|
||||
const prevCount = webhooksStore.deliveries.length
|
||||
await webhooksStore.fetchDeliveries(props.webhookId, {
|
||||
cursor: last.id,
|
||||
status: currentStatus(),
|
||||
append: true,
|
||||
})
|
||||
hasMore.value = webhooksStore.deliveries.length - prevCount >= 20
|
||||
}
|
||||
|
||||
function setupInfiniteScroll() {
|
||||
if (!scrollContainer.value) return
|
||||
useInfiniteScroll(scrollContainer, () => {
|
||||
loadMore()
|
||||
}, {
|
||||
distance: 200,
|
||||
canLoadMore: () => hasMore.value && !webhooksStore.deliveriesLoading,
|
||||
})
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open && props.webhookId) {
|
||||
loadInitial()
|
||||
}
|
||||
})
|
||||
|
||||
watch(statusFilter, () => {
|
||||
if (isOpen.value) {
|
||||
loadInitial()
|
||||
}
|
||||
})
|
||||
|
||||
function statusColor(status: string) {
|
||||
if (status === 'success') return 'success' as const
|
||||
if (status === 'failed') return 'error' as const
|
||||
return 'warning' as const
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string | null) {
|
||||
if (!dateStr) return '—'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function openPayload(delivery: WebhookDeliveryItem) {
|
||||
selectedPayload.value = delivery.payload
|
||||
showPayload.value = true
|
||||
}
|
||||
|
||||
async function retryDelivery(delivery: WebhookDeliveryItem) {
|
||||
retrying.value = delivery.id
|
||||
await webhooksStore.retryDelivery(delivery.id)
|
||||
await loadInitial()
|
||||
retrying.value = null
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<UIcon
|
||||
name="i-lucide-webhook"
|
||||
class="size-6 shrink-0"
|
||||
:class="webhook.isActive ? 'text-primary' : 'text-muted'"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium truncate">
|
||||
{{ webhook.url }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1 mt-1 flex-wrap">
|
||||
<UBadge
|
||||
v-for="event in webhook.events"
|
||||
:key="event"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ event }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="canManage"
|
||||
class="flex items-center gap-2 shrink-0 ml-2"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-pencil"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
:title="t('webhooks.edit')"
|
||||
@click="emit('edit', webhook)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-send"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
:loading="testing"
|
||||
:title="t('webhooks.test')"
|
||||
@click="handleTest"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-history"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
:title="t('webhooks.deliveries')"
|
||||
@click="emit('showDeliveries', webhook.id)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-key-round"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
:title="t('webhooks.rotateSecret')"
|
||||
@click="showRotateConfirm = true"
|
||||
/>
|
||||
<USwitch
|
||||
:model-value="webhook.isActive"
|
||||
size="md"
|
||||
@update:model-value="handleToggle"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="md"
|
||||
@click="emit('delete', webhook.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UModal v-model:open="showRotateConfirm" :fullscreen="isMobile">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('webhooks.rotateSecret') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-sm">
|
||||
{{ t('webhooks.rotateConfirm') }}
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
@click="showRotateConfirm = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('webhooks.rotateSecret')"
|
||||
color="error"
|
||||
:loading="rotating"
|
||||
@click="handleRotateSecret"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="showRotatedSecret" :fullscreen="isMobile">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('webhooks.secretTitle') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-sm text-muted mb-3">
|
||||
{{ t('webhooks.secretDescription') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-2 p-3 bg-elevated rounded-lg">
|
||||
<span class="font-mono text-sm break-all">{{ rotatedSecret }}</span>
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
@click="copy(rotatedSecret)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end">
|
||||
<UButton
|
||||
:label="t('common.done')"
|
||||
color="primary"
|
||||
@click="showRotatedSecret = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import type { WebhookItem } from 'taskview-api'
|
||||
import { useWebhooksStore } from '@/stores/webhooks.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const props = defineProps<{
|
||||
webhook: WebhookItem
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [id: number, isActive: boolean]
|
||||
delete: [id: number]
|
||||
showDeliveries: [id: number]
|
||||
edit: [webhook: WebhookItem]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const { copy } = useClipboard()
|
||||
const webhooksStore = useWebhooksStore()
|
||||
|
||||
const testing = ref(false)
|
||||
const showRotateConfirm = ref(false)
|
||||
const showRotatedSecret = ref(false)
|
||||
const rotatedSecret = ref('')
|
||||
const rotating = ref(false)
|
||||
|
||||
async function handleTest() {
|
||||
testing.value = true
|
||||
await webhooksStore.testDelivery(props.webhook.id)
|
||||
testing.value = false
|
||||
}
|
||||
|
||||
function handleToggle(isActive: boolean) {
|
||||
emit('toggle', props.webhook.id, isActive)
|
||||
}
|
||||
|
||||
async function handleRotateSecret() {
|
||||
rotating.value = true
|
||||
try {
|
||||
const secret = await webhooksStore.rotateSecret(props.webhook.id)
|
||||
if (secret) {
|
||||
showRotateConfirm.value = false
|
||||
rotatedSecret.value = secret
|
||||
showRotatedSecret.value = true
|
||||
}
|
||||
} finally {
|
||||
rotating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,10 +1,12 @@
|
||||
import { watch, type Ref } from 'vue'
|
||||
import { useCollaborationStore } from '@/stores/collaboration.store'
|
||||
import { useGoalListsStore } from '@/stores/goal-lists.store'
|
||||
import { useKanbanStore } from '@/stores/kanban.store'
|
||||
import { useTagsStore } from '@/stores/tag.store'
|
||||
|
||||
export function useProjectDataLoader(projectId: Ref<number>) {
|
||||
const collaborationStore = useCollaborationStore()
|
||||
const goalListsStore = useGoalListsStore()
|
||||
const kanbanStore = useKanbanStore()
|
||||
const tagsStore = useTagsStore()
|
||||
|
||||
@@ -15,6 +17,7 @@ export function useProjectDataLoader(projectId: Ref<number>) {
|
||||
Promise.all([
|
||||
kanbanStore.fetchStatuses(id),
|
||||
collaborationStore.fetchCollaborationUsersForGoal(id),
|
||||
goalListsStore.fetchLists(id),
|
||||
])
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ref } from 'vue'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { FirebaseMessaging } from '@capacitor-firebase/messaging'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
import { useNotificationsStore } from '@/stores/notifications.store'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ALL_TASKS_LIST_ID } from 'taskview-api'
|
||||
|
||||
const registered = ref(false)
|
||||
let initialized = false
|
||||
|
||||
export function usePushNotifications() {
|
||||
const supported = Capacitor.isNativePlatform()
|
||||
const router = useRouter()
|
||||
|
||||
async function init() {
|
||||
console.log('-------------------------------- init push notifications --------------------------------')
|
||||
if (initialized || !supported) return
|
||||
initialized = true
|
||||
|
||||
const permission = await FirebaseMessaging.requestPermissions()
|
||||
if (permission.receive !== 'granted') return
|
||||
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const { token } = await FirebaseMessaging.getToken()
|
||||
if (token) {
|
||||
const platform = Capacitor.getPlatform() as 'android' | 'ios'
|
||||
await $tvApi.notifications.registerDevice(token, platform, timezone)
|
||||
registered.value = true
|
||||
}
|
||||
|
||||
FirebaseMessaging.addListener('tokenReceived', async ({ token }) => {
|
||||
const platform = Capacitor.getPlatform() as 'android' | 'ios'
|
||||
await $tvApi.notifications.registerDevice(token, platform, timezone)
|
||||
registered.value = true
|
||||
})
|
||||
|
||||
FirebaseMessaging.addListener('notificationReceived', (_notification) => {
|
||||
const notificationsStore = useNotificationsStore()
|
||||
notificationsStore.fetchNotifications(true)
|
||||
})
|
||||
|
||||
FirebaseMessaging.addListener('notificationActionPerformed', (action) => {
|
||||
const data = action.notification.data as Record<string, string> | undefined
|
||||
if (data?.taskId && data?.goalId) {
|
||||
router.push({
|
||||
name: 'user',
|
||||
params: {
|
||||
projectId: data.goalId,
|
||||
listId: data.goalListId || ALL_TASKS_LIST_ID,
|
||||
taskId: data.taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { supported, registered, init }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { SplashScreen } from '@capacitor/splash-screen'
|
||||
import { type BundleInfo, CapacitorUpdater } from '@capgo/capacitor-updater'
|
||||
@@ -21,61 +22,63 @@ function isNewerVersion(server: string, current: string): boolean {
|
||||
}
|
||||
|
||||
export async function useUpdater(canUpdate: boolean = false) {
|
||||
try {
|
||||
const BRANCH_MODE: 'prod' | 'dev' = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
|
||||
console.log(canUpdate);
|
||||
return;
|
||||
// try {
|
||||
// const BRANCH_MODE: 'prod' | 'dev' = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
|
||||
|
||||
const updateInfo = await $api.get<AppResponse<{ version: string; file: string; branch: 'prod' | 'dev' }[]>>(
|
||||
`${useTaskViewMainUrl()}/module/updates/info`,
|
||||
)
|
||||
// const updateInfo = await $api.get<AppResponse<{ version: string; file: string; branch: 'prod' | 'dev' }[]>>(
|
||||
// `${useTaskViewMainUrl()}/module/updates/info`,
|
||||
// )
|
||||
|
||||
const { value: currentVersion } = await Preferences.get({ key: 'app_version' })
|
||||
// const { value: currentVersion } = await Preferences.get({ key: 'app_version' })
|
||||
|
||||
const last = updateInfo.data.response.find((i) => i.branch === BRANCH_MODE)
|
||||
// const last = updateInfo.data.response.find((i) => i.branch === BRANCH_MODE)
|
||||
|
||||
console.log('branch', BRANCH_MODE)
|
||||
// console.log('branch', BRANCH_MODE)
|
||||
|
||||
if (!last) {
|
||||
console.log('[Update] No new updates for this branch.', BRANCH_MODE)
|
||||
return
|
||||
}
|
||||
// if (!last) {
|
||||
// console.log('[Update] No new updates for this branch.', BRANCH_MODE)
|
||||
// return
|
||||
// }
|
||||
|
||||
console.log('currentVersion', currentVersion, 'updateInfo', last)
|
||||
// console.log('currentVersion', currentVersion, 'updateInfo', last)
|
||||
|
||||
if (!currentVersion || isNewerVersion(last.version, currentVersion) || (version && version?.version !== last.version)) {
|
||||
console.log('[Update] Downloading new version:', last.version)
|
||||
// if (!currentVersion || isNewerVersion(last.version, currentVersion) || (version && version?.version !== last.version)) {
|
||||
// console.log('[Update] Downloading new version:', last.version)
|
||||
|
||||
version = await CapacitorUpdater.download({
|
||||
version: last.version,
|
||||
url: `${useTaskViewMainUrl()}/module/updates/download?branch=${BRANCH_MODE}`,
|
||||
})
|
||||
} else {
|
||||
console.log('[Update] No new updates need to download.')
|
||||
}
|
||||
// version = await CapacitorUpdater.download({
|
||||
// version: last.version,
|
||||
// url: `${useTaskViewMainUrl()}/module/updates/download?branch=${BRANCH_MODE}`,
|
||||
// })
|
||||
// } else {
|
||||
// console.log('[Update] No new updates need to download.')
|
||||
// }
|
||||
|
||||
if (canUpdate && version) {
|
||||
try {
|
||||
console.log('[Update] New version available:', last.version)
|
||||
// if (canUpdate && version) {
|
||||
// try {
|
||||
// console.log('[Update] New version available:', last.version)
|
||||
|
||||
SplashScreen.show()
|
||||
// SplashScreen.show()
|
||||
|
||||
console.log('[Update] Version:', version)
|
||||
// console.log('[Update] Version:', version)
|
||||
|
||||
await CapacitorUpdater.next(version)
|
||||
// await CapacitorUpdater.next(version)
|
||||
|
||||
await Preferences.set({ key: 'app_version', value: last.version })
|
||||
// await Preferences.set({ key: 'app_version', value: last.version })
|
||||
|
||||
await CapacitorUpdater.set(version)
|
||||
// await CapacitorUpdater.set(version)
|
||||
|
||||
console.log('[set version]', last.version)
|
||||
// console.log('[set version]', last.version)
|
||||
|
||||
console.log('[Update] Update applied. Will take effect after restart.')
|
||||
} catch (err: unknown) {
|
||||
console.log(err)
|
||||
} finally {
|
||||
SplashScreen.hide()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Update] Error checking for update:', err)
|
||||
}
|
||||
// console.log('[Update] Update applied. Will take effect after restart.')
|
||||
// } catch (err: unknown) {
|
||||
// console.log(err)
|
||||
// } finally {
|
||||
// SplashScreen.hide()
|
||||
// }
|
||||
// }
|
||||
// } catch (err) {
|
||||
// console.warn('[Update] Error checking for update:', err)
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -47,9 +47,11 @@ import { useI18n } from 'vue-i18n'
|
||||
import ProjectsSidebar from '@/components/features/projects/ProjectsSidebar.vue'
|
||||
import NotificationBell from '@/components/NotificationBell.vue'
|
||||
import { useCentrifugo } from '@/composables/useCentrifugo'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications'
|
||||
|
||||
const { isSidebarOpen, isSidebarCollapsed } = useDashboard()
|
||||
const { connect: connectCentrifugo } = useCentrifugo()
|
||||
const { init: initPush } = usePushNotifications()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -75,8 +77,10 @@ App.addListener('appStateChange', async ({ isActive }) => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('-------------------------------- onMounted push notifications --------------------------------')
|
||||
appStore.initTaskDetailDisplayMode()
|
||||
connectCentrifugo()
|
||||
initPush()
|
||||
|
||||
await CapacitorUpdater.notifyAppReady()
|
||||
console.log('notifyAppReady', APP_VERSION)
|
||||
|
||||
@@ -97,6 +97,7 @@ export default {
|
||||
graph: 'Graph',
|
||||
collaboration: 'Collaboration',
|
||||
integrations: 'Integrations',
|
||||
webhooks: 'Webhooks',
|
||||
edit: 'Edit',
|
||||
moveToArchive: 'Move to Archive',
|
||||
restoreFromArchive: 'Restore from Archive',
|
||||
@@ -286,6 +287,33 @@ export default {
|
||||
sync: 'Sync',
|
||||
syncing: 'Syncing...',
|
||||
},
|
||||
webhooks: {
|
||||
title: 'Webhooks',
|
||||
selectProject: 'Select a project to manage webhooks',
|
||||
add: 'Add Webhook',
|
||||
empty: 'No webhooks yet. Add a webhook to receive event notifications.',
|
||||
url: 'URL',
|
||||
urlPlaceholder: 'https://example.com/webhook',
|
||||
events: 'Events',
|
||||
selectEvents: 'Select events',
|
||||
test: 'Test',
|
||||
deliveries: 'Deliveries',
|
||||
noDeliveries: 'No deliveries yet',
|
||||
rotateSecret: 'Rotate secret',
|
||||
secretTitle: 'Webhook Secret',
|
||||
secretDescription: 'Copy this secret and store it securely. It will not be shown again.',
|
||||
event: 'Event',
|
||||
status: 'Status',
|
||||
httpCode: 'HTTP Code',
|
||||
attempts: 'Attempts',
|
||||
time: 'Time',
|
||||
payload: 'Payload',
|
||||
viewPayload: 'View payload',
|
||||
copy: 'Copy',
|
||||
edit: 'Edit',
|
||||
allStatuses: 'All statuses',
|
||||
rotateConfirm: 'Are you sure you want to rotate the secret? The current secret will stop working immediately and all webhook consumers will need to be updated with the new secret.',
|
||||
},
|
||||
main: 'Main',
|
||||
mainScreen: {
|
||||
welcome: 'Welcome',
|
||||
@@ -422,6 +450,19 @@ export default {
|
||||
empty: 'No notifications',
|
||||
markAllRead: 'Mark all read',
|
||||
loadMore: 'Load more',
|
||||
settings: {
|
||||
title: 'Notification Settings',
|
||||
description: 'Choose which notifications you want to receive and how.',
|
||||
push: 'Push',
|
||||
websocket: 'In-app',
|
||||
types: {
|
||||
deadline: 'Deadlines',
|
||||
deadlineDescription: 'When a task deadline is reached',
|
||||
assign: 'Assignments',
|
||||
assignDescription: 'When you are assigned to a task',
|
||||
},
|
||||
saved: 'Settings saved',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
selectServer: 'Server',
|
||||
|
||||
@@ -64,6 +64,11 @@ const router = createRouter({
|
||||
name: 'integrations',
|
||||
component: () => import('./pages/user/integrations.vue'),
|
||||
},
|
||||
{
|
||||
path: ':projectId/webhooks',
|
||||
name: 'webhooks',
|
||||
component: () => import('./pages/user/webhooks.vue'),
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
name: 'account',
|
||||
|
||||
@@ -3,26 +3,56 @@
|
||||
id="graph"
|
||||
>
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="`${projectName} - ${t('graph.title')}`">
|
||||
<UDashboardNavbar
|
||||
:title="`${projectName} - ${t('graph.title')}`"
|
||||
:ui="{title: 'shrink-0'}"
|
||||
>
|
||||
<template #leading>
|
||||
<TvCollapseSidebarDesktop />
|
||||
</template>
|
||||
<template #right>
|
||||
<div class="w-full">
|
||||
<UButton
|
||||
icon="i-lucide-filter"
|
||||
:color="showFilters ? 'primary' : 'neutral'"
|
||||
variant="soft"
|
||||
size="lg"
|
||||
:ui="{leadingIcon: 'size-4'}"
|
||||
@click="showFilters = !showFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<ProjectGraph />
|
||||
<div class="relative h-full">
|
||||
<div
|
||||
v-if="showFilters"
|
||||
class="rounded absolute top-0 left-0 right-0 z-10 overflow-x-auto border-b border-default bg-elevated/90 backdrop-blur-sm px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2 w-fit ml-auto">
|
||||
<TvKanbanFilters
|
||||
v-model:list-ids="selectedListIds"
|
||||
v-model:assignee-ids="selectedAssigneeIds"
|
||||
show-reset-label
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectGraph v-model:list-ids="selectedListIds" v-model:assignee-ids="selectedAssigneeIds" class="h-full w-full" />
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
<TvTaskDetailOverlay />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProjectGraph from '@/components/features/graph/ProjectGraph.vue'
|
||||
import TvCollapseSidebarDesktop from '@/components/features/base/TvCollapseSidebarDesktop.vue'
|
||||
import TvTaskDetailOverlay from '@/components/features/tasks/TvTaskDetailOverlay.vue'
|
||||
import TvKanbanFilters from '@/components/features/base/TvKanbanFilters.vue'
|
||||
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useProjectDataLoader } from '@/composables/useProjectDataLoader'
|
||||
@@ -32,5 +62,9 @@ const { projectId } = useAppRouteInfo()
|
||||
const goalsStore = useGoalsStore()
|
||||
const projectName = computed(() => goalsStore.goalMap.get(projectId.value)?.name ?? '')
|
||||
|
||||
const showFilters = ref(false)
|
||||
const selectedListIds = ref<number[]>([])
|
||||
const selectedAssigneeIds = ref<number[]>([])
|
||||
|
||||
useProjectDataLoader(projectId)
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,38 @@
|
||||
<template #leading>
|
||||
<TvCollapseSidebarDesktop />
|
||||
</template>
|
||||
<template #right>
|
||||
<div class="flex items-center gap-2 overflow-x-auto">
|
||||
<div v-if="showFilters" class="hidden lg:contents">
|
||||
<TvKanbanFilters
|
||||
v-model:list-ids="selectedListIds"
|
||||
v-model:assignee-ids="selectedAssigneeIds"
|
||||
show-reset-label
|
||||
/>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-lucide-filter"
|
||||
:color="showFilters ? 'primary' : 'neutral'"
|
||||
variant="soft"
|
||||
size="lg"
|
||||
class="shrink-0"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="showFilters = !showFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
<div
|
||||
v-if="showFilters"
|
||||
class="lg:hidden overflow-x-auto border-b border-default px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 w-fit ml-auto">
|
||||
<TvKanbanFilters
|
||||
v-model:list-ids="selectedListIds"
|
||||
v-model:assignee-ids="selectedAssigneeIds"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
@@ -19,19 +50,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import KanbanBoard from '@/components/features/kanban/KanbanBoard.vue'
|
||||
import TvCollapseSidebarDesktop from '@/components/features/base/TvCollapseSidebarDesktop.vue'
|
||||
import TvTaskDetailOverlay from '@/components/features/tasks/TvTaskDetailOverlay.vue'
|
||||
import TvKanbanFilters from '@/components/features/base/TvKanbanFilters.vue'
|
||||
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
|
||||
import { useProjectDataLoader } from '@/composables/useProjectDataLoader'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useKanbanStore } from '@/stores/kanban.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { projectId } = useAppRouteInfo()
|
||||
const goalsStore = useGoalsStore()
|
||||
const kanbanStore = useKanbanStore()
|
||||
const projectName = computed(() => goalsStore.goalMap.get(projectId.value)?.name ?? '')
|
||||
|
||||
const showFilters = ref(false)
|
||||
const selectedListIds = ref<number[]>([])
|
||||
const selectedAssigneeIds = ref<number[]>([])
|
||||
|
||||
watch([selectedListIds, selectedAssigneeIds], ([listIds, assigneeIds]) => {
|
||||
kanbanStore.setFilters({ listIds, assigneeIds })
|
||||
})
|
||||
|
||||
useProjectDataLoader(projectId)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<UDashboardPanel id="webhooks">
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="t('webhooks.title')">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<WebhooksPanel />
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WebhooksPanel from '@/components/features/webhooks/WebhooksPanel.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user