feat(server): support read all notifications (#13083)

close AF-2719



#### PR Dependency Tree


* **PR #13083** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added the ability to mark all notifications as read with a single
action.
  
* **Bug Fixes**
  * Ensured notifications marked as read are no longer shown as unread.

* **Tests**
* Introduced new tests to verify the functionality of marking all
notifications as read.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fengmk2
2025-07-08 15:19:45 +08:00
committed by GitHub
parent 6fd9524521
commit db79c00ea7
9 changed files with 122 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ import {
notificationCountQuery,
NotificationObjectType,
NotificationType,
readAllNotificationsMutation,
readNotificationMutation,
} from '@affine/graphql';
@@ -677,3 +678,41 @@ e2e('should list and count notifications', async t => {
t.is(result3.currentUser!.notifications.edges.length, 0);
}
});
e2e('should mark all notifications as read', async t => {
const { member, owner, workspace } = await init();
await app.login(owner);
await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-1',
title: 'doc-title-1',
blockId: 'block-id-1',
mode: DocMode.page,
},
},
},
});
await app.login(member);
await app.gql({
query: readAllNotificationsMutation,
});
const result = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 10,
offset: 0,
},
},
});
t.is(result.currentUser!.notifications.totalCount, 0);
});

View File

@@ -100,6 +100,14 @@ export class UserNotificationResolver {
await this.service.markAsRead(me.id, notificationId);
return true;
}
@Mutation(() => Boolean, {
description: 'mark all notifications as read',
})
async readAllNotifications(@CurrentUser() me: UserType) {
await this.service.markAllAsRead(me.id);
return true;
}
}
@Resolver(() => NotificationObjectType)

View File

@@ -399,6 +399,10 @@ export class NotificationService {
}
}
async markAllAsRead(userId: string) {
await this.models.notification.markAllAsRead(userId);
}
/**
* Find notifications by user id, order by createdAt desc
*/

View File

@@ -430,3 +430,32 @@ test('should create a comment mention notification', async t => {
t.is(notification.body.commentId, commentId);
t.is(notification.body.replyId, replyId);
});
test('should mark all notifications as read', async t => {
await models.notification.createMention({
userId: user.id,
body: {
workspaceId: workspace.id,
doc: {
id: docId,
title: 'doc-title',
blockId: 'blockId',
mode: DocMode.page,
},
createdByUserId: createdBy.id,
},
});
await models.notification.createInvitation({
userId: user.id,
body: {
workspaceId: workspace.id,
createdByUserId: createdBy.id,
inviteId: randomUUID(),
},
});
await models.notification.markAllAsRead(user.id);
const notifications = await models.notification.findManyByUserId(user.id);
t.is(notifications.length, 0);
});

View File

@@ -261,6 +261,18 @@ export class NotificationModel extends BaseModel {
});
}
async markAllAsRead(userId: string) {
const { count } = await this.db.notification.updateMany({
where: { userId },
data: {
read: true,
},
});
this.logger.log(
`Marked all notifications as read for user ${userId}, count: ${count}`
);
}
/**
* Find many notifications by user id, exclude read notifications by default
*/

View File

@@ -1236,6 +1236,9 @@ type Mutation {
"""queue workspace doc embedding"""
queueWorkspaceEmbedding(docId: [String!]!, workspaceId: String!): Boolean!
"""mark all notifications as read"""
readAllNotifications: Boolean!
"""mark notification as read"""
readNotification(id: String!): Boolean!
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!

View File

@@ -1989,6 +1989,14 @@ export const quotaQuery = {
deprecations: ["'storageQuota' is deprecated: use `UserQuotaType['usedStorageQuota']` instead"],
};
export const readAllNotificationsMutation = {
id: 'readAllNotificationsMutation' as const,
op: 'readAllNotifications',
query: `mutation readAllNotifications {
readAllNotifications
}`,
};
export const readNotificationMutation = {
id: 'readNotificationMutation' as const,
op: 'readNotification',

View File

@@ -0,0 +1,3 @@
mutation readAllNotifications {
readAllNotifications
}

View File

@@ -1371,6 +1371,8 @@ export interface Mutation {
publishPage: DocType;
/** queue workspace doc embedding */
queueWorkspaceEmbedding: Scalars['Boolean']['output'];
/** mark all notifications as read */
readAllNotifications: Scalars['Boolean']['output'];
/** mark notification as read */
readNotification: Scalars['Boolean']['output'];
recoverDoc: Scalars['DateTime']['output'];
@@ -5218,6 +5220,15 @@ export type QuotaQuery = {
} | null;
};
export type ReadAllNotificationsMutationVariables = Exact<{
[key: string]: never;
}>;
export type ReadAllNotificationsMutation = {
__typename?: 'Mutation';
readAllNotifications: boolean;
};
export type ReadNotificationMutationVariables = Exact<{
id: Scalars['String']['input'];
}>;
@@ -6352,6 +6363,11 @@ export type Mutations =
variables: PublishPageMutationVariables;
response: PublishPageMutation;
}
| {
name: 'readAllNotificationsMutation';
variables: ReadAllNotificationsMutationVariables;
response: ReadAllNotificationsMutation;
}
| {
name: 'readNotificationMutation';
variables: ReadNotificationMutationVariables;