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

@@ -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;
}

View File

@@ -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: {

View File

@@ -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],

View 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),
},
};
}

View File

@@ -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],

View File

@@ -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: '勋章发放' },

View File

@@ -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],

View 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)])),
};
}

View File

@@ -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,

View File

@@ -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];
});

View File

@@ -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');
}

View File

@@ -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';