Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/web/src/app/api/pages/[pageId]/tasks/[taskId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { canUserEditPage } from '@pagespace/lib/server';
import { broadcastTaskEvent, broadcastPageEvent, createPageEventPayload } from '@/lib/websocket';
import { getActorInfo, logPageActivity } from '@pagespace/lib/monitoring/activity-logger';
import { applyPageMutation, PageRevisionMismatchError } from '@/services/api/page-mutation-service';
import { createTaskAssignedNotification } from '@pagespace/lib/notifications';

const AUTH_OPTIONS = { allow: ['jwt', 'mcp'] as const, requireCSRF: true };

Expand Down Expand Up @@ -220,6 +221,18 @@ export async function PATCH(
return NextResponse.json({ error: 'Task not found after update' }, { status: 404 });
}

// Send notification if assignee was changed to a new user
if (assigneeId !== undefined && assigneeId !== existingTask.assigneeId && assigneeId !== null) {
// Fire-and-forget notification - don't block the response
void createTaskAssignedNotification(
assigneeId,
taskId,
taskWithRelations.title,
pageId,
userId
);
}

// Broadcast events
const broadcasts: Promise<void>[] = [
broadcastTaskEvent({
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/components/notifications/NotificationDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import {
CheckCheck,
Bell,
MessageCircle,
Mail
Mail,
AtSign,
ListTodo
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
Expand Down Expand Up @@ -53,6 +55,10 @@ const NotificationIcon = ({ type }: { type: string }) => {
case 'DRIVE_JOINED':
case 'DRIVE_ROLE_CHANGED':
return <Users className="h-4 w-4" />;
case 'MENTION':
return <AtSign className="h-4 w-4" />;
case 'TASK_ASSIGNED':
return <ListTodo className="h-4 w-4" />;
default:
return <FileText className="h-4 w-4" />;
}
Expand Down Expand Up @@ -193,6 +199,12 @@ export default function NotificationDropdown() {
// Navigate to the direct message conversation
setIsDropdownOpen(false);
router.push(`/dashboard/messages/${notification.metadata.conversationId}`);
} else if ((notification.type === 'MENTION' || notification.type === 'TASK_ASSIGNED') &&
notification.pageId &&
notification.driveId) {
// Navigate to the page where user was mentioned or task list
setIsDropdownOpen(false);
router.push(`/dashboard/${notification.driveId}/${notification.pageId}`);
} else if (notification.drive?.id) {
// Navigate to drive if available
setIsDropdownOpen(false);
Expand Down
106 changes: 98 additions & 8 deletions apps/web/src/services/api/page-mention-service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import * as cheerio from 'cheerio';
import { db, mentions, eq, and, inArray } from '@pagespace/db';
import { db, mentions, userMentions, eq, and, inArray } from '@pagespace/db';
import { loggers } from '@pagespace/lib/server';

type TransactionType = Parameters<Parameters<typeof db.transaction>[0]>[0];
type DatabaseType = typeof db;

function findMentionNodes(content: unknown): string[] {
const ids: string[] = [];
interface MentionIds {
pageIds: string[];
userIds: string[];
}

function findMentionNodes(content: unknown): MentionIds {
const pageIds: string[] = [];
const userIds: string[] = [];
const contentStr = Array.isArray(content) ? content.join('\n') : String(content);

const shouldParseHtml = contentStr.includes('<') && contentStr.includes('data-page-id');
Expand All @@ -15,10 +21,18 @@ function findMentionNodes(content: unknown): string[] {
if (shouldParseHtml) {
try {
const $ = cheerio.load(contentStr);
// Parse page mentions from HTML
$('a[data-page-id]').each((_, element) => {
const pageId = $(element).attr('data-page-id');
if (pageId) {
ids.push(pageId);
pageIds.push(pageId);
}
});
// Parse user mentions from HTML
$('a[data-user-id]').each((_, element) => {
const userId = $(element).attr('data-user-id');
if (userId) {
userIds.push(userId);
}
});
} catch (error) {
Expand All @@ -28,22 +42,62 @@ function findMentionNodes(content: unknown): string[] {
}

if (!shouldParseHtml || parseFailed) {
const regex = /@\[.*?\]\((.*?)\)/g;
// Parse markdown-style mentions: @[Label](id:type)
const regex = /@\[([^\]]*)\]\(([^:)]+):?([^)]*)\)/g;
let match;
while ((match = regex.exec(contentStr)) !== null) {
ids.push(match[1]);
const id = match[2];
const type = match[3] || 'page'; // Default to page if no type specified
if (type === 'user') {
userIds.push(id);
} else {
pageIds.push(id);
}
}
}

return Array.from(new Set(ids));
return {
pageIds: Array.from(new Set(pageIds)),
userIds: Array.from(new Set(userIds)),
};
}

export interface SyncMentionsOptions {
mentionedByUserId?: string;
}

export interface SyncMentionsResult {
newlyMentionedUserIds: string[];
sourcePageId: string;
mentionedByUserId?: string;
}

export async function syncMentions(
sourcePageId: string,
content: unknown,
tx: TransactionType | DatabaseType,
options?: SyncMentionsOptions
): Promise<SyncMentionsResult> {
const { pageIds: mentionedPageIds, userIds: mentionedUserIds } = findMentionNodes(content);

// Sync page mentions
await syncPageMentions(sourcePageId, mentionedPageIds, tx);

// Sync user mentions and get newly created user IDs
const newlyMentionedUserIds = await syncUserMentions(sourcePageId, mentionedUserIds, tx, options?.mentionedByUserId);

return {
newlyMentionedUserIds,
sourcePageId,
mentionedByUserId: options?.mentionedByUserId,
};
}

async function syncPageMentions(
sourcePageId: string,
mentionedPageIds: string[],
tx: TransactionType | DatabaseType
): Promise<void> {
const mentionedPageIds = findMentionNodes(content);
const mentionedPageIdSet = new Set(mentionedPageIds);

const existingMentionsQuery = await tx
Expand All @@ -69,3 +123,39 @@ export async function syncMentions(
));
}
}

async function syncUserMentions(
sourcePageId: string,
mentionedUserIds: string[],
tx: TransactionType | DatabaseType,
mentionedByUserId?: string
): Promise<string[]> {
const mentionedUserIdSet = new Set(mentionedUserIds);

const existingMentionsQuery = await tx
.select({ targetUserId: userMentions.targetUserId })
.from(userMentions)
.where(eq(userMentions.sourcePageId, sourcePageId));
const existingMentionUserIds = new Set(existingMentionsQuery.map(m => m.targetUserId));

const toCreate = mentionedUserIds.filter(id => !existingMentionUserIds.has(id));
const toDelete = Array.from(existingMentionUserIds).filter(id => !mentionedUserIdSet.has(id));

if (toCreate.length > 0) {
await tx.insert(userMentions).values(toCreate.map(targetUserId => ({
sourcePageId,
targetUserId,
mentionedByUserId: mentionedByUserId || null,
})));
}

if (toDelete.length > 0) {
await tx.delete(userMentions).where(and(
eq(userMentions.sourcePageId, sourcePageId),
inArray(userMentions.targetUserId, toDelete)
));
}

// Return newly created user IDs so caller can send notifications after transaction commits
return toCreate;
}
22 changes: 20 additions & 2 deletions apps/web/src/services/api/page-mutation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {
computePageStateHash,
createPageVersion,
type PageVersionSource,
loggers,
} from '@pagespace/lib/server';
import { writePageContent } from '@pagespace/lib/server';
import { detectPageContentFormat, type PageContentFormat } from '@pagespace/lib/content';
import { hashWithPrefix } from '@pagespace/lib/server';
import { syncMentions } from '@/services/api/page-mention-service';
import { syncMentions, type SyncMentionsResult } from '@/services/api/page-mention-service';
import { createMentionNotification } from '@pagespace/lib/notifications';

export class PageRevisionMismatchError extends Error {
currentRevision: number;
Expand Down Expand Up @@ -187,6 +189,9 @@ export async function applyPageMutation({
newValues[field] = updates[field];
}

// Track newly mentioned users to send notifications after transaction commits
let mentionsResult: SyncMentionsResult | null = null;

const applyMutationInTx = async (transaction: typeof db) => {
const updateWhere = expectedRevision !== undefined
? and(eq(pages.id, pageId), eq(pages.revision, expectedRevision))
Expand All @@ -212,7 +217,7 @@ export async function applyPageMutation({
}

if (updates.content !== undefined) {
await syncMentions(pageId, nextContent, transaction);
mentionsResult = await syncMentions(pageId, nextContent, transaction, { mentionedByUserId: context.userId });
}

await logActivityWithTx({
Expand Down Expand Up @@ -268,6 +273,19 @@ export async function applyPageMutation({
});
}

// Send notifications for newly mentioned users after transaction commits (fire-and-forget)
if (mentionsResult) {
const result = mentionsResult as SyncMentionsResult;
if (result.mentionedByUserId && result.newlyMentionedUserIds.length > 0) {
for (const targetUserId of result.newlyMentionedUserIds) {
createMentionNotification(targetUserId, result.sourcePageId, result.mentionedByUserId)
.catch((error: unknown) => {
loggers.api.error('Failed to send mention notification:', error as Error);
});
}
}
}

return {
pageId,
driveId: currentPage.driveId,
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/services/api/rollback-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ import {
type ChangeGroupType,
} from '@pagespace/lib/server';
import { detectPageContentFormat, type PageContentFormat } from '@pagespace/lib/content';
import { syncMentions } from '@/services/api/page-mention-service';
import { syncMentions, type SyncMentionsResult } from '@/services/api/page-mention-service';
import { createMentionNotification } from '@pagespace/lib/notifications';

/**
* Valid activity operations for filtering
Expand Down Expand Up @@ -283,6 +284,7 @@ interface PageMutationMeta {
contentRefAfter: string | null;
contentSizeAfter: number | null;
contentFormatAfter: PageContentFormat;
mentionsResult?: SyncMentionsResult;
}

interface PageChangeResult {
Expand Down Expand Up @@ -390,8 +392,9 @@ async function applyPageUpdateWithRevision(
throw new Error('Page was modified while applying rollback');
}

let mentionsResult: SyncMentionsResult | undefined;
if (updateData.content !== undefined) {
await syncMentions(pageId, nextContent, database);
mentionsResult = await syncMentions(pageId, nextContent, database, { mentionedByUserId: options?.userId ?? undefined });
}

const changeGroupId = options?.changeGroupId ?? createChangeGroupId();
Expand Down Expand Up @@ -419,6 +422,7 @@ async function applyPageUpdateWithRevision(
contentRefAfter: version.contentRef ?? contentRefAfter ?? null,
contentSizeAfter: version.contentSize ?? null,
contentFormatAfter,
mentionsResult,
};
}

Expand Down Expand Up @@ -1797,6 +1801,17 @@ export async function executeRollback(
resourceId: activity.resourceId,
});

// Send notifications for newly mentioned users after all operations complete (fire-and-forget)
const mentionsResult = pageMutationMeta?.mentionsResult;
if (mentionsResult && mentionsResult.mentionedByUserId && mentionsResult.newlyMentionedUserIds.length > 0) {
for (const targetUserId of mentionsResult.newlyMentionedUserIds) {
createMentionNotification(targetUserId, mentionsResult.sourcePageId, mentionsResult.mentionedByUserId)
.catch((error: unknown) => {
loggers.api.error('Failed to send mention notification:', error as Error);
});
}
}

return {
success: true,
action: 'rollback',
Expand Down
31 changes: 31 additions & 0 deletions packages/db/drizzle/0038_salty_phalanx.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
ALTER TYPE "NotificationType" ADD VALUE 'MENTION';--> statement-breakpoint
ALTER TYPE "NotificationType" ADD VALUE 'TASK_ASSIGNED';--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "user_mentions" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"sourcePageId" text NOT NULL,
"targetUserId" text NOT NULL,
"mentionedByUserId" text
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_mentions" ADD CONSTRAINT "user_mentions_sourcePageId_pages_id_fk" FOREIGN KEY ("sourcePageId") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_mentions" ADD CONSTRAINT "user_mentions_targetUserId_users_id_fk" FOREIGN KEY ("targetUserId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_mentions" ADD CONSTRAINT "user_mentions_mentionedByUserId_users_id_fk" FOREIGN KEY ("mentionedByUserId") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_mentions_source_page_id_target_user_id_key" ON "user_mentions" USING btree ("sourcePageId","targetUserId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_mentions_source_page_id_idx" ON "user_mentions" USING btree ("sourcePageId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_mentions_target_user_id_idx" ON "user_mentions" USING btree ("targetUserId");
Loading