wip: notifications

This commit is contained in:
Nikolai Giman
2026-03-22 20:01:05 +01:00
parent 5c4859743e
commit c403673f4d
108 changed files with 5662 additions and 582 deletions
+4
View File
@@ -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
View File
@@ -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"
}
}
}
+4
View File
@@ -0,0 +1,4 @@
export interface Dispatcher {
register(): void;
registerWorkers(): Promise<void>;
}
+4 -4
View File
@@ -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;
+22
View File
@@ -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}"`);
}
}
+12 -9
View File
@@ -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();
}
}
+22
View File
@@ -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);
+2
View File
@@ -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 }));
};
+3 -3
View File
@@ -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) {
+16 -2
View File
@@ -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;
}
}
@@ -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;
}
}
}
+123
View File
@@ -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;
}
+42
View File
@@ -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;
}
}
+2 -2
View File
@@ -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 */
+19 -6
View File
@@ -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 };
}
+26 -5
View File
@@ -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);
}
}
+66
View File
@@ -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;
}
+99
View File
@@ -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 |
+170
View File
@@ -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
```
+5
View File
@@ -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"
+1284 -97
View File
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];
+2 -1
View File
@@ -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';
+5
View File
@@ -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;
+29
View File
@@ -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;
@@ -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",
+8
View File
@@ -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>
+16
View File
@@ -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)
}
}
+4
View File
@@ -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")
]
+30
View File
@@ -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>
+3
View File
@@ -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",
+99 -65
View File
@@ -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 }
}
+44 -41
View File
@@ -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)
// }
}
+4
View File
@@ -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)
+41
View File
@@ -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',
+5
View File
@@ -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',
+37 -3
View File
@@ -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>
+43 -1
View File
@@ -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>
+21
View File
@@ -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