forked from wangziqi/gongxue-base
feat: add user notification center
This commit is contained in:
180
apps/api/src/features/notifications/service.ts
Normal file
180
apps/api/src/features/notifications/service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type pg from 'pg';
|
||||
import { HttpError } from '../../core/http.js';
|
||||
|
||||
export type NotificationStatus = 'unread' | 'read' | 'dismissed' | 'archived';
|
||||
export type NotificationSeverity = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface UserNotificationRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
notificationType: string;
|
||||
status: NotificationStatus;
|
||||
severity: NotificationSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
actionLabel: string | null;
|
||||
actionPath: string | null;
|
||||
sourceType: string | null;
|
||||
sourceId: string | null;
|
||||
dedupeKey: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdBy: string | null;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateUserNotificationInput {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
notificationType: string;
|
||||
severity?: NotificationSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
actionLabel?: string | null;
|
||||
actionPath?: string | null;
|
||||
sourceType?: string | null;
|
||||
sourceId?: string | null;
|
||||
dedupeKey?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
export const USER_NOTIFICATION_STATUSES: NotificationStatus[] = ['unread', 'read', 'dismissed', 'archived'];
|
||||
export const USER_NOTIFICATION_SEVERITIES: NotificationSeverity[] = ['info', 'success', 'warning', 'error'];
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function safeText(value: unknown, key: string, maxLength: number) {
|
||||
const text = nullableString(value);
|
||||
if (!text) throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD');
|
||||
if (text.length > maxLength) {
|
||||
throw new HttpError(400, `${key} is too long`, 'FIELD_TOO_LONG');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function safeNullableText(value: unknown, key: string, maxLength: number) {
|
||||
const text = nullableString(value);
|
||||
if (!text) return null;
|
||||
if (text.length > maxLength) {
|
||||
throw new HttpError(400, `${key} is too long`, 'FIELD_TOO_LONG');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function safeToken(value: unknown, key: string, fallback: string | null = null) {
|
||||
const text = nullableString(value) || fallback;
|
||||
if (!text) return null;
|
||||
if (!/^[a-z][a-z0-9_:-]{1,95}$/i.test(text)) {
|
||||
throw new HttpError(400, `${key} contains unsupported characters`, 'INVALID_NOTIFICATION_TOKEN');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function safeDedupeKey(value: unknown) {
|
||||
const text = nullableString(value);
|
||||
if (!text) return null;
|
||||
if (!/^[a-zA-Z0-9:_./-]{4,180}$/.test(text)) {
|
||||
throw new HttpError(400, 'dedupeKey contains unsupported characters', 'INVALID_NOTIFICATION_DEDUPE_KEY');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function safeUuid(value: unknown, key: string) {
|
||||
const text = nullableString(value);
|
||||
if (!text) return null;
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) {
|
||||
throw new HttpError(400, `${key} must be a UUID`, 'INVALID_UUID');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
export function normalizeNotificationStatus(value: unknown, fallback: NotificationStatus = 'unread') {
|
||||
const status = (nullableString(value) || fallback) as NotificationStatus;
|
||||
if (!USER_NOTIFICATION_STATUSES.includes(status)) {
|
||||
throw new HttpError(400, `Invalid notification status: ${status}`, 'INVALID_NOTIFICATION_STATUS');
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export function normalizeNotificationSeverity(value: unknown, fallback: NotificationSeverity = 'info') {
|
||||
const severity = (nullableString(value) || fallback) as NotificationSeverity;
|
||||
if (!USER_NOTIFICATION_SEVERITIES.includes(severity)) {
|
||||
throw new HttpError(400, `Invalid notification severity: ${severity}`, 'INVALID_NOTIFICATION_SEVERITY');
|
||||
}
|
||||
return severity;
|
||||
}
|
||||
|
||||
export async function createUserNotification(
|
||||
client: Pick<pg.PoolClient, 'query'>,
|
||||
input: CreateUserNotificationInput,
|
||||
): Promise<UserNotificationRow | null> {
|
||||
const notificationType = safeToken(input.notificationType, 'notificationType');
|
||||
const severity = normalizeNotificationSeverity(input.severity, 'info');
|
||||
const title = safeText(input.title, 'title', 120);
|
||||
const message = safeText(input.message, 'message', 600);
|
||||
const actionLabel = safeNullableText(input.actionLabel, 'actionLabel', 40);
|
||||
const actionPath = safeNullableText(input.actionPath, 'actionPath', 240);
|
||||
const sourceType = safeToken(input.sourceType, 'sourceType', null);
|
||||
const sourceId = safeUuid(input.sourceId, 'sourceId');
|
||||
const dedupeKey = safeDedupeKey(input.dedupeKey);
|
||||
|
||||
const result = await client.query<UserNotificationRow>(
|
||||
`
|
||||
insert into public.user_notifications (
|
||||
tenant_id, user_id, notification_type, status, severity,
|
||||
title, message, action_label, action_path,
|
||||
source_type, source_id, dedupe_key, metadata, created_by
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 'unread', $4,
|
||||
$5, $6, $7, $8,
|
||||
$9, $10::uuid, $11, $12::jsonb, $13::uuid
|
||||
)
|
||||
on conflict (tenant_id, user_id, notification_type, dedupe_key)
|
||||
where dedupe_key is not null
|
||||
do update set severity = excluded.severity,
|
||||
title = excluded.title,
|
||||
message = excluded.message,
|
||||
action_label = excluded.action_label,
|
||||
action_path = excluded.action_path,
|
||||
source_type = excluded.source_type,
|
||||
source_id = excluded.source_id,
|
||||
metadata = public.user_notifications.metadata || excluded.metadata,
|
||||
created_by = coalesce(public.user_notifications.created_by, excluded.created_by),
|
||||
updated_at = now()
|
||||
returning id, tenant_id as "tenantId", user_id as "userId",
|
||||
notification_type as "notificationType", status, severity,
|
||||
title, message, action_label as "actionLabel",
|
||||
action_path as "actionPath", source_type as "sourceType",
|
||||
source_id as "sourceId", dedupe_key as "dedupeKey",
|
||||
metadata, created_by as "createdBy", read_at as "readAt",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.userId,
|
||||
notificationType,
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
actionLabel,
|
||||
actionPath,
|
||||
sourceType,
|
||||
sourceId,
|
||||
dedupeKey,
|
||||
JSON.stringify(objectValue(input.metadata)),
|
||||
safeUuid(input.createdBy, 'createdBy'),
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type pg from 'pg';
|
||||
import { createUserNotification } from '../notifications/service.js';
|
||||
|
||||
type JsonMap = Record<string, unknown>;
|
||||
|
||||
@@ -212,6 +213,28 @@ export async function autoGrantBadges(
|
||||
);
|
||||
|
||||
if (!result.rows[0]) continue;
|
||||
await createUserNotification(client, {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
notificationType: 'badge_granted',
|
||||
severity: 'success',
|
||||
title: `获得勋章:${badge.name}`,
|
||||
message: badge.description || '你已解锁新的学习勋章。',
|
||||
actionLabel: '查看勋章',
|
||||
actionPath: '/student/profile?tab=badges',
|
||||
sourceType: 'user_badges',
|
||||
sourceId: result.rows[0].id,
|
||||
dedupeKey: `badge:${badge.id}:user:${input.userId}`,
|
||||
metadata: {
|
||||
source: 'auto_badge_grant',
|
||||
trigger: input.trigger,
|
||||
badgeId: badge.id,
|
||||
badgeName: badge.name,
|
||||
badgeCategory: badge.category,
|
||||
badgeLevel: badge.level,
|
||||
evidence: input.evidence,
|
||||
},
|
||||
});
|
||||
grants.push({
|
||||
...result.rows[0],
|
||||
badge: {
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
exchangeItemsRoute,
|
||||
redeemExchangeItemRoute,
|
||||
} from './points.js';
|
||||
import {
|
||||
profileNotificationsRoute,
|
||||
updateProfileNotificationStatusRoute,
|
||||
} from './notifications.js';
|
||||
|
||||
export const profileRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/profile/me', profileMeRoute],
|
||||
@@ -25,6 +29,8 @@ export const profileRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/profile/activity-tasks/claim', claimActivityTaskRoute],
|
||||
['GET', '/api/profile/exchange-items', exchangeItemsRoute],
|
||||
['POST', '/api/profile/exchange-items/redeem', redeemExchangeItemRoute],
|
||||
['GET', '/api/profile/notifications', profileNotificationsRoute],
|
||||
['POST', '/api/profile/notifications/status', updateProfileNotificationStatusRoute],
|
||||
['GET', '/api/profile/badges', profileBadgesRoute],
|
||||
['GET', '/api/profile/feedbacks', feedbacksRoute],
|
||||
['POST', '/api/profile/feedbacks', submitFeedbackRoute],
|
||||
|
||||
129
apps/api/src/features/profile/notifications.ts
Normal file
129
apps/api/src/features/profile/notifications.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query } from '../../core/db.js';
|
||||
import { normalizeNotificationStatus, USER_NOTIFICATION_STATUSES } from '../notifications/service.js';
|
||||
|
||||
const NOTIFICATION_TYPES = [
|
||||
'feedback_status_updated',
|
||||
'feedback_reward_granted',
|
||||
'badge_granted',
|
||||
'point_exchange_completed',
|
||||
'point_exchange_pending_fulfillment',
|
||||
];
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function notificationIdsValue(value: unknown) {
|
||||
if (!Array.isArray(value)) throw new HttpError(400, 'notificationIds is required', 'NOTIFICATION_IDS_REQUIRED');
|
||||
const ids = value.map(item => {
|
||||
const id = nullableString(item);
|
||||
if (!id || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||||
throw new HttpError(400, 'notificationIds must contain UUID values', 'INVALID_UUID');
|
||||
}
|
||||
return id;
|
||||
});
|
||||
if (ids.length === 0) throw new HttpError(400, 'notificationIds cannot be empty', 'NOTIFICATION_IDS_REQUIRED');
|
||||
if (ids.length > 100) throw new HttpError(400, 'notificationIds cannot exceed 100 items', 'NOTIFICATION_IDS_TOO_MANY');
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
function notificationTypeParam(ctx: RequestContext) {
|
||||
const notificationType = stringParam(ctx, 'notificationType') || stringParam(ctx, 'type');
|
||||
if (!notificationType) return '';
|
||||
if (!NOTIFICATION_TYPES.includes(notificationType)) {
|
||||
throw new HttpError(400, `Invalid notificationType: ${notificationType}`, 'INVALID_NOTIFICATION_TYPE');
|
||||
}
|
||||
return notificationType;
|
||||
}
|
||||
|
||||
export async function profileNotificationsRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
const status = stringParam(ctx, 'status');
|
||||
const notificationType = notificationTypeParam(ctx);
|
||||
const params: unknown[] = [tenantId, userId];
|
||||
const filters = ['tenant_id = $1', 'user_id = $2'];
|
||||
|
||||
if (status) {
|
||||
normalizeNotificationStatus(status);
|
||||
params.push(status);
|
||||
filters.push(`status = $${params.length}`);
|
||||
}
|
||||
if (notificationType) {
|
||||
params.push(notificationType);
|
||||
filters.push(`notification_type = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, notification_type as "notificationType", status, severity,
|
||||
title, message, action_label as "actionLabel",
|
||||
action_path as "actionPath", source_type as "sourceType",
|
||||
source_id as "sourceId", metadata, read_at as "readAt",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.user_notifications
|
||||
where ${filters.join(' and ')}
|
||||
order by created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
const summaryRows = await query<{ status: string; count: number }>(
|
||||
`
|
||||
select status, count(*)::int as count
|
||||
from public.user_notifications
|
||||
where tenant_id = $1 and user_id = $2
|
||||
group by status
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
const summary = Object.fromEntries(USER_NOTIFICATION_STATUSES.map(item => [item, 0]));
|
||||
for (const row of summaryRows) summary[row.status] = Number(row.count || 0);
|
||||
|
||||
return { items, summary };
|
||||
}
|
||||
|
||||
export async function updateProfileNotificationStatusRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const notificationIds = notificationIdsValue(body.notificationIds);
|
||||
const status = normalizeNotificationStatus(body.status, 'read');
|
||||
if (status === 'unread') {
|
||||
throw new HttpError(400, 'Use read, dismissed or archived for notification status updates', 'INVALID_NOTIFICATION_STATUS');
|
||||
}
|
||||
|
||||
const result = await query<{ id: string; notificationType: string; status: string; readAt: string | null; updatedAt: string }>(
|
||||
`
|
||||
update public.user_notifications
|
||||
set status = $4,
|
||||
read_at = case
|
||||
when $4 = 'read' then coalesce(read_at, now())
|
||||
else read_at
|
||||
end,
|
||||
updated_at = now()
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and id = any($3::uuid[])
|
||||
returning id, notification_type as "notificationType", status, read_at as "readAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[tenantId, userId, notificationIds, status],
|
||||
);
|
||||
|
||||
if (result.length !== notificationIds.length) {
|
||||
throw new HttpError(404, 'Some notifications were not found for this user', 'NOTIFICATION_NOT_FOUND');
|
||||
}
|
||||
|
||||
return {
|
||||
item: {
|
||||
updatedCount: result.length,
|
||||
status,
|
||||
notificationIds: result.map(item => item.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, transaction } from '../../core/db.js';
|
||||
import { autoGrantBadges } from './badges.js';
|
||||
import { createUserNotification } from '../notifications/service.js';
|
||||
|
||||
type JsonMap = Record<string, unknown>;
|
||||
|
||||
@@ -771,6 +772,33 @@ export async function redeemExchangeItemRoute(ctx: RequestContext) {
|
||||
[userId, exchangeItem.costPoints],
|
||||
);
|
||||
|
||||
const finalStatus = order.rows[0].status;
|
||||
await createUserNotification(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
notificationType: finalStatus === 'completed' ? 'point_exchange_completed' : 'point_exchange_pending_fulfillment',
|
||||
severity: finalStatus === 'completed' ? 'success' : 'info',
|
||||
title: finalStatus === 'completed' ? '积分兑换成功' : '积分兑换待发放',
|
||||
message: finalStatus === 'completed'
|
||||
? `你已成功兑换「${exchangeItem.title}」。`
|
||||
: `你已提交「${exchangeItem.title}」兑换申请,运营人员会继续处理。`,
|
||||
actionLabel: '查看兑换',
|
||||
actionPath: '/student/profile?tab=points',
|
||||
sourceType: 'user_point_exchange_orders',
|
||||
sourceId: order.rows[0].id,
|
||||
dedupeKey: `point_exchange:${order.rows[0].id}`,
|
||||
metadata: {
|
||||
itemId: exchangeItem.id,
|
||||
itemCode: exchangeItem.code,
|
||||
itemTitle: exchangeItem.title,
|
||||
itemType: exchangeItem.itemType,
|
||||
costPoints: exchangeItem.costPoints,
|
||||
status: finalStatus,
|
||||
couponRedemptionId: order.rows[0].couponRedemptionId,
|
||||
assetId: order.rows[0].assetId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
item: exchangeItem,
|
||||
order: order.rows[0],
|
||||
|
||||
@@ -13,7 +13,7 @@ const TENANT_ADMIN_ROLES = new Set([
|
||||
const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
|
||||
tenant_owner: ['*'],
|
||||
tenant_admin: ['*'],
|
||||
tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'badges:*', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'],
|
||||
tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'badges:*', 'notifications:read', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'],
|
||||
teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'],
|
||||
sales: ['codes:*', 'coupons:read', 'coupons:write', 'referral:*', 'commission:self'],
|
||||
agent: ['codes:read', 'coupons:read', 'referral:self', 'commission:self'],
|
||||
@@ -105,6 +105,7 @@ export function tenantPermissionCatalog() {
|
||||
{ key: 'marketing:write', label: '活动内容管理' },
|
||||
{ key: 'marketing:points:read', label: '积分任务/兑换查看' },
|
||||
{ key: 'marketing:points:write', label: '积分任务/兑换管理' },
|
||||
{ key: 'notifications:read', label: '用户站内通知查看' },
|
||||
{ key: 'badges:read', label: '勋章查看' },
|
||||
{ key: 'badges:write', label: '勋章管理' },
|
||||
{ key: 'badges:grant', label: '勋章发放' },
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
upsertTenantStudentRoute,
|
||||
} from './classes.js';
|
||||
import { tenantDashboardRoute } from './dashboard.js';
|
||||
import { tenantUserNotificationsRoute } from './notifications.js';
|
||||
import {
|
||||
tenantExamDatesRoute,
|
||||
tenantFeedbackEventsRoute,
|
||||
@@ -101,6 +102,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant-admin/teachers', tenantTeachersRoute],
|
||||
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
|
||||
['GET', '/api/tenant-admin/dashboard', tenantDashboardRoute],
|
||||
['GET', '/api/tenant-admin/user-notifications', tenantUserNotificationsRoute],
|
||||
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
|
||||
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
|
||||
['GET', '/api/tenant-admin/theme-templates', tenantThemeTemplatesRoute],
|
||||
|
||||
92
apps/api/src/features/tenant-admin/notifications.ts
Normal file
92
apps/api/src/features/tenant-admin/notifications.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, stringParam } from '../../core/request.js';
|
||||
import { query } from '../../core/db.js';
|
||||
import { normalizeNotificationStatus } from '../notifications/service.js';
|
||||
import { requireTenantAdmin, requireTenantPermission } from './auth.js';
|
||||
|
||||
const NOTIFICATION_TYPES = [
|
||||
'feedback_status_updated',
|
||||
'feedback_reward_granted',
|
||||
'badge_granted',
|
||||
'point_exchange_completed',
|
||||
'point_exchange_pending_fulfillment',
|
||||
];
|
||||
|
||||
function optionalUuidString(value: unknown, key: string) {
|
||||
const candidate = typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
if (!candidate) return null;
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate)) {
|
||||
throw new HttpError(400, `${key} must be a UUID`, 'INVALID_UUID');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function notificationTypeParam(ctx: RequestContext) {
|
||||
const notificationType = stringParam(ctx, 'notificationType') || stringParam(ctx, 'type');
|
||||
if (!notificationType) return '';
|
||||
if (!NOTIFICATION_TYPES.includes(notificationType)) {
|
||||
throw new HttpError(400, `Invalid notificationType: ${notificationType}`, 'INVALID_NOTIFICATION_TYPE');
|
||||
}
|
||||
return notificationType;
|
||||
}
|
||||
|
||||
export async function tenantUserNotificationsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'notifications:read');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const userId = optionalUuidString(stringParam(ctx, 'userId'), 'userId');
|
||||
const status = stringParam(ctx, 'status');
|
||||
const notificationType = notificationTypeParam(ctx);
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['n.tenant_id = $1'];
|
||||
|
||||
if (userId) {
|
||||
params.push(userId);
|
||||
filters.push(`n.user_id = $${params.length}::uuid`);
|
||||
}
|
||||
if (status) {
|
||||
normalizeNotificationStatus(status);
|
||||
params.push(status);
|
||||
filters.push(`n.status = $${params.length}`);
|
||||
}
|
||||
if (notificationType) {
|
||||
params.push(notificationType);
|
||||
filters.push(`n.notification_type = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select n.id, n.user_id as "userId", u.name as "userName",
|
||||
u.phone as "userPhone", u.avatar_url as "userAvatarUrl",
|
||||
n.notification_type as "notificationType", n.status, n.severity,
|
||||
n.title, n.message, n.action_label as "actionLabel",
|
||||
n.action_path as "actionPath", n.source_type as "sourceType",
|
||||
n.source_id as "sourceId", n.metadata, n.created_by as "createdBy",
|
||||
creator.name as "createdByName", n.read_at as "readAt",
|
||||
n.created_at as "createdAt", n.updated_at as "updatedAt"
|
||||
from public.user_notifications n
|
||||
join public.platform_users u on u.id = n.user_id
|
||||
left join public.platform_users creator on creator.id = n.created_by
|
||||
where ${filters.join(' and ')}
|
||||
order by n.created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
const summaryRows = await query<{ status: string; count: number }>(
|
||||
`
|
||||
select status, count(*)::int as count
|
||||
from public.user_notifications
|
||||
where tenant_id = $1
|
||||
group by status
|
||||
`,
|
||||
[auth.tenantId],
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
summary: Object.fromEntries(summaryRows.map(item => [item.status, Number(item.count || 0)])),
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { query, transaction } from '../../core/db.js';
|
||||
import { autoGrantBadges, type AutoBadgeGrant } from '../profile/badges.js';
|
||||
import { createUserNotification } from '../notifications/service.js';
|
||||
import {
|
||||
requireTenantAdmin,
|
||||
requireTenantPermission,
|
||||
@@ -13,6 +14,13 @@ type JsonBody = Record<string, unknown>;
|
||||
|
||||
const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed'];
|
||||
const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
||||
const REPORT_STATUS_LABELS: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
accepted: '已受理',
|
||||
rejected: '未采纳',
|
||||
resolved: '已解决',
|
||||
closed: '已关闭',
|
||||
};
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
@@ -31,6 +39,12 @@ function boolValue(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function feedbackNotificationSeverity(status: string) {
|
||||
if (status === 'resolved') return 'success';
|
||||
if (status === 'rejected') return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
||||
const candidate = nullableString(value) || fallback;
|
||||
if (!allowed.includes(candidate)) {
|
||||
@@ -401,6 +415,55 @@ export async function updateTenantFeedbackStatusRoute(ctx: RequestContext) {
|
||||
];
|
||||
}
|
||||
|
||||
if (current.rows[0].userId) {
|
||||
const statusLabel = REPORT_STATUS_LABELS[nextStatus] || nextStatus;
|
||||
const resolution = nullableString(body.resolution);
|
||||
await createUserNotification(client, {
|
||||
tenantId: auth.tenantId,
|
||||
userId: current.rows[0].userId,
|
||||
notificationType: 'feedback_status_updated',
|
||||
severity: feedbackNotificationSeverity(nextStatus),
|
||||
title: `反馈${statusLabel}`,
|
||||
message: resolution || `你的反馈已更新为「${statusLabel}」。`,
|
||||
actionLabel: '查看反馈',
|
||||
actionPath: '/student/profile?tab=feedbacks',
|
||||
sourceType: 'reports',
|
||||
sourceId: reportId,
|
||||
dedupeKey: `feedback_status:${reportId}:${nextStatus}`,
|
||||
metadata: {
|
||||
reportId,
|
||||
fromStatus: current.rows[0].status,
|
||||
toStatus: nextStatus,
|
||||
rewardPoints,
|
||||
autoBadgeCount: autoBadges.length,
|
||||
},
|
||||
createdBy: auth.userId,
|
||||
});
|
||||
|
||||
if (reward) {
|
||||
await createUserNotification(client, {
|
||||
tenantId: auth.tenantId,
|
||||
userId: current.rows[0].userId,
|
||||
notificationType: 'feedback_reward_granted',
|
||||
severity: 'success',
|
||||
title: '反馈奖励已发放',
|
||||
message: `感谢你的反馈,本次已奖励 ${rewardPoints} 积分。`,
|
||||
actionLabel: '查看积分',
|
||||
actionPath: '/student/profile?tab=points',
|
||||
sourceType: 'reports',
|
||||
sourceId: reportId,
|
||||
dedupeKey: `feedback_reward:${reportId}`,
|
||||
metadata: {
|
||||
reportId,
|
||||
rewardPoints,
|
||||
scoreEventId: reward.id,
|
||||
balanceAfter: reward.balanceAfter,
|
||||
},
|
||||
createdBy: auth.userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(client, auth, 'tenant.feedback.status_updated', 'reports', reportId, {
|
||||
fromStatus: current.rows[0].status,
|
||||
toStatus: nextStatus,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
tenantPermissionCatalog,
|
||||
type TenantAdminAuth,
|
||||
} from './auth.js';
|
||||
import { createUserNotification } from '../notifications/service.js';
|
||||
|
||||
type JsonBody = Record<string, unknown>;
|
||||
type SecretScope = 'payment' | 'sms' | 'oauth' | 'storage' | 'crm' | 'ai' | 'system';
|
||||
@@ -1954,6 +1955,27 @@ export async function grantBadgeRoute(ctx: RequestContext) {
|
||||
badgeName: badge.rows[0].name,
|
||||
});
|
||||
|
||||
await createUserNotification(client, {
|
||||
tenantId: auth.tenantId,
|
||||
userId,
|
||||
notificationType: 'badge_granted',
|
||||
severity: 'success',
|
||||
title: `获得勋章:${badge.rows[0].name}`,
|
||||
message: nullableString(body.note) || '管理员为你发放了一枚新的学习勋章。',
|
||||
actionLabel: '查看勋章',
|
||||
actionPath: '/student/profile?tab=badges',
|
||||
sourceType: 'user_badges',
|
||||
sourceId: result.rows[0].id,
|
||||
dedupeKey: `badge:${badgeId}:user:${userId}`,
|
||||
metadata: {
|
||||
source: 'tenant_admin_badge_grant',
|
||||
badgeId,
|
||||
badgeName: badge.rows[0].name,
|
||||
grantId: result.rows[0].id,
|
||||
},
|
||||
createdBy: auth.userId,
|
||||
});
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
|
||||
@@ -66,6 +66,23 @@ export interface PointExchangeItem {
|
||||
lastExchangedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UserNotificationItem {
|
||||
id: string;
|
||||
notificationType?: string;
|
||||
status?: 'unread' | 'read' | 'dismissed' | 'archived';
|
||||
severity?: 'info' | 'success' | 'warning' | 'error';
|
||||
title?: string;
|
||||
message?: string;
|
||||
actionLabel?: string | null;
|
||||
actionPath?: string | null;
|
||||
sourceType?: string | null;
|
||||
sourceId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
readAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export async function loadProfile() {
|
||||
return apiRequest<{ item?: StudentProfile }>('/api/profile/me');
|
||||
}
|
||||
@@ -127,6 +144,26 @@ export async function redeemExchangeItem(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadNotifications(query: {
|
||||
status?: UserNotificationItem['status'];
|
||||
notificationType?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
return apiRequest<{ items?: UserNotificationItem[]; summary?: Record<string, number> }>('/api/profile/notifications', {
|
||||
query: { ...query, limit: query.limit || 50 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateNotificationStatus(input: {
|
||||
notificationIds: string[];
|
||||
status: 'read' | 'dismissed' | 'archived';
|
||||
}) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/profile/notifications/status', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadBadges() {
|
||||
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/profile/badges');
|
||||
}
|
||||
|
||||
@@ -236,6 +236,29 @@ export interface TenantContentNotificationItem {
|
||||
resolvedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UserNotificationAdminItem {
|
||||
id: string;
|
||||
userId?: string;
|
||||
userName?: string | null;
|
||||
userPhone?: string | null;
|
||||
userAvatarUrl?: string | null;
|
||||
notificationType?: string;
|
||||
status?: 'unread' | 'read' | 'dismissed' | 'archived';
|
||||
severity?: 'info' | 'success' | 'warning' | 'error';
|
||||
title?: string;
|
||||
message?: string;
|
||||
actionLabel?: string | null;
|
||||
actionPath?: string | null;
|
||||
sourceType?: string | null;
|
||||
sourceId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdBy?: string | null;
|
||||
createdByName?: string | null;
|
||||
readAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ImportIssueItem {
|
||||
id: string;
|
||||
rowNo?: number | null;
|
||||
@@ -1008,6 +1031,17 @@ export async function loadTenantContentNotifications(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadUserNotifications(input: {
|
||||
userId?: string;
|
||||
status?: UserNotificationAdminItem['status'];
|
||||
notificationType?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
return apiRequest<{ items?: UserNotificationAdminItem[]; summary?: Record<string, number> }>('/api/tenant-admin/user-notifications', {
|
||||
query: { ...input, limit: input.limit || 100 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTenantContentNotificationStatus(input: {
|
||||
notificationIds: string[];
|
||||
status: 'read' | 'dismissed' | 'resolved';
|
||||
|
||||
Reference in New Issue
Block a user