feat: add user notification center

This commit is contained in:
Codex
2026-06-30 03:56:41 +08:00
parent 2fea68fe4d
commit d72ff499ce
24 changed files with 895 additions and 44 deletions

View File

@@ -1774,6 +1774,38 @@ async function testProfile() {
'profile badges should expose auto score badge as unlocked',
);
const badgeNotifications = await request('/api/profile/notifications', {
query: { notificationType: 'badge_granted', status: 'unread', limit: 20 },
});
assert.ok(
badgeNotifications.items?.some(item => item.metadata?.badgeId === ids.tenantCheckInBadge),
'auto badge grant should create unread student notification',
);
assert.equal(
badgeNotifications.summary?.unread >= 1,
true,
'profile notification summary should count unread notifications',
);
const firstBadgeNotification = badgeNotifications.items?.find(item => item.metadata?.badgeId === ids.tenantCheckInBadge);
const markedNotification = await request('/api/profile/notifications/status', {
method: 'POST',
body: {
notificationIds: [firstBadgeNotification.id],
status: 'read',
},
});
assert.equal(markedNotification.item?.updatedCount, 1, 'student should mark own notification as read');
const otherUserNotifications = await request('/api/profile/notifications', {
userId: SECOND_STUDENT_USER_ID,
query: { notificationType: 'badge_granted', limit: 20 },
});
assert.ok(
!otherUserNotifications.items?.some(item => item.metadata?.badgeId === ids.tenantCheckInBadge),
'another student must not see current student notifications',
);
const feedback = await request('/api/profile/feedbacks', {
method: 'POST',
body: {
@@ -6572,6 +6604,16 @@ async function testTenantAdminOps() {
assert.equal(grant.item?.badgeId, ids.tenantBadge, 'tenant admin should grant badge');
assert.equal(grant.item?.userId, USER_ID, 'badge grant should target student');
const manualBadgeNotifications = await request('/api/profile/notifications', {
query: { notificationType: 'badge_granted', limit: 50 },
});
assert.ok(
manualBadgeNotifications.items?.some(
item => item.metadata?.source === 'tenant_admin_badge_grant' && item.metadata?.badgeId === ids.tenantBadge,
),
'manual badge grant should create a student notification',
);
const repeatedGrant = await request('/api/tenant-admin/badge-grants', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
@@ -6684,6 +6726,38 @@ async function testTenantAdminOps() {
'resolved feedback should auto grant feedback badge',
);
const feedbackNotifications = await request('/api/profile/notifications', {
query: { limit: 80 },
});
assert.ok(
feedbackNotifications.items?.some(
item => item.notificationType === 'feedback_status_updated' && item.sourceId === pendingFeedback.id && item.status === 'unread',
),
'feedback status update should create unread student notification',
);
assert.ok(
feedbackNotifications.items?.some(
item => item.notificationType === 'feedback_reward_granted' && item.sourceId === pendingFeedback.id,
),
'feedback reward should create student reward notification',
);
const tenantUserNotifications = await request('/api/tenant-admin/user-notifications', {
userId: TENANT_ADMIN_USER_ID,
query: { userId: USER_ID, notificationType: 'feedback_status_updated', limit: 20 },
});
assert.ok(
tenantUserNotifications.items?.some(item => item.sourceId === pendingFeedback.id && item.userId === USER_ID),
'tenant admin should inspect student notifications with permission',
);
const tenantNotificationDenied = await request('/api/tenant-admin/user-notifications', {
userId: TENANT_TEACHER_USER_ID,
query: { userId: USER_ID, limit: 20 },
expectStatus: 403,
});
assert.equal(tenantNotificationDenied.code, 'TENANT_PERMISSION_REQUIRED', 'tenant user notification list requires explicit permission');
const rewardEventsAfterResolve = await request('/api/profile/score-events', { query: { limit: 50 } });
const rewardCountAfterResolve = rewardEventsAfterResolve.items?.filter(
item => item.eventType === 'feedback_reward' && item.sourceId === pendingFeedback.id,
@@ -6976,6 +7050,14 @@ async function testPointActivitiesAndExchange() {
assert.equal(redeemed.item?.order?.itemId, ids.pointExchangeItem, 'exchange order should bind item');
assert.ok(redeemed.item?.couponRedemption?.id, 'coupon exchange should create coupon redemption');
const exchangeNotifications = await request('/api/profile/notifications', {
query: { notificationType: 'point_exchange_completed', status: 'unread', limit: 20 },
});
assert.ok(
exchangeNotifications.items?.some(item => item.sourceId === redeemed.item?.order?.id && item.metadata?.itemId === ids.pointExchangeItem),
'completed point exchange should create unread student notification',
);
const repeatedRedeem = await request('/api/profile/exchange-items/redeem', {
method: 'POST',
body: {
@@ -7055,6 +7137,7 @@ async function testTenantMemberPermissionsAndAudit() {
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:read'), 'permission matrix should expose point marketing read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:write'), 'permission matrix should expose point marketing write permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'notifications:read'), 'permission matrix should expose user notification read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission');

View File

@@ -199,6 +199,24 @@ async function main() {
],
);
await client.query(
`
delete from public.user_notifications
where tenant_id = $1
and (
user_id in ($2::uuid, $3::uuid)
or dedupe_key like any($4::text[])
or source_type in ('reports', 'user_badges', 'user_point_exchange_orders')
)
`,
[
tenantId,
ids.user,
ids.secondStudentUser,
['feedback_%', 'badge:%', 'point_exchange:%'],
],
);
await client.query(
`
delete from public.report_status_events
@@ -339,12 +357,13 @@ async function main() {
tenantId,
ids.user,
ids.secondStudentUser,
[ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge],
[ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge, ids.tenantActivityBadge],
[
`badge:${ids.tenantBadge}:user:${ids.user}`,
`auto:${ids.tenantCheckInBadge}:user:${ids.user}`,
`auto:${ids.tenantScoreBadge}:user:${ids.user}`,
`auto:${ids.tenantFeedbackBadge}:user:${ids.user}`,
`auto:${ids.tenantActivityBadge}:user:${ids.user}`,
],
],
);
@@ -358,7 +377,7 @@ async function main() {
or coalesce(legacy_id, '') like 'integration-badge-%'
)
`,
[tenantId, [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge]],
[tenantId, [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge, ids.tenantActivityBadge]],
);
await client.query(