feat(notification): 完整站内消息推送体系 (Phase 1-4)
## Phase 1 — 精准推送基础 - 新增 notification-service 微服务 (port 3013) - DB迁移 007: notifications, notification_reads, notification_tenant_targets 表 - DB迁移 008: tenant_tags, tenant_tag_assignments, notification_user_targets 表 + notifications 表新增 target_tag_ids/target_tag_logic/target_plans/target_statuses/channel_key 字段 - auth-service: TenantTagController — 租户标签 CRUD + 批量分配 (9个接口) - notification-service 支持 7 种推送目标类型: ALL / SPECIFIC_TENANTS / SPECIFIC_USERS / BY_TENANT_TAG(ANY|ALL) / BY_PLAN / BY_TENANT_STATUS / BY_SEGMENT - Web Admin: /tenant-tags 标签管理页 + 通知表单全面扩展 ## Phase 2 — 通知频道与用户偏好 - DB迁移 009: notification_channels (6个预置频道) + user_notification_preferences + notification_segment_members 表 (Phase 4 人群包) - notification-service: ChannelRepository + NotificationChannelController (频道 CRUD + 用户偏好 API,强制频道不可关闭) - Web Admin: /notification-channels 频道管理页 - Flutter: NotificationPreferencesPage — 用户按频道 toggle 订阅,profile页新增入口 ## Phase 3 — Campaign 活动与数据分析 - DB迁移 010: notification_campaigns, campaign_execution_log, notification_event_log 表 - notification-service: CampaignRepository + CampaignAdminController (ONCE/RECURRING调度, 排期/取消/删除, 发送量/阅读率统计) - Web Admin: /campaigns 推送活动管理页 (状态机 + 数据统计弹窗) ## Phase 4 — 事件触发与人群包 - EventTriggerService: Redis Stream 消费者,监听并自动创建通知: billing.payment_failed / billing.quota_warning / tenant.registered / alert.fired - SegmentRepository + SegmentAdminController (全量同步/增量添加/删除) - Web Admin: /segments 人群包管理页 (成员管理 + ETL全量替换) ## 基础设施 - Kong: 新增 notification-service 服务 + 6条路由 + JWT插件 - Docker Compose: 新增 notification-service 容器 (13013:3013) - notification-service 新增 ioredis 依赖 (Redis Stream 消费) ## Flutter (APK需手动编译) - 新增路由: /notifications/inbox, /notifications/preferences - 新增: NotificationInboxPage, NotificationPreferencesPage - 新增: ForceReadNotificationDialog (强制阅读拦截弹窗) - profile页: 站内消息行(未读角标) + 通知偏好设置入口 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3020ecc465
commit
5ff8bda99e
|
|
@ -71,6 +71,8 @@ services:
|
|||
condition: service_healthy
|
||||
referral-service:
|
||||
condition: service_healthy
|
||||
notification-service:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "kong", "health"]
|
||||
interval: 10s
|
||||
|
|
@ -469,6 +471,37 @@ services:
|
|||
networks:
|
||||
- it0-network
|
||||
|
||||
notification-service:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Dockerfile.service
|
||||
args:
|
||||
SERVICE_NAME: notification-service
|
||||
SERVICE_PORT: 3013
|
||||
container_name: it0-notification-service
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "13013:3013"
|
||||
environment:
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USERNAME=${POSTGRES_USER:-it0}
|
||||
- DB_PASSWORD=${POSTGRES_PASSWORD:-it0_dev}
|
||||
- DB_DATABASE=${POSTGRES_DB:-it0}
|
||||
- NOTIFICATION_SERVICE_PORT=3013
|
||||
- JWT_SECRET=${JWT_SECRET:-dev-jwt-secret}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3013/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- it0-network
|
||||
|
||||
# ===== LiveKit Infrastructure =====
|
||||
# NOTE: livekit-server, voice-agent, voice-service use host networking
|
||||
# to eliminate docker-proxy overhead for real-time audio (WebRTC UDP).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,488 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Megaphone, Plus, RefreshCw, Pencil, Trash2, X, BarChart2, Play, Ban } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type CampaignStatus = 'DRAFT' | 'SCHEDULED' | 'RUNNING' | 'COMPLETED' | 'CANCELLED' | 'FAILED';
|
||||
type ScheduleType = 'ONCE' | 'RECURRING';
|
||||
|
||||
interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: CampaignStatus;
|
||||
notificationId: string | null;
|
||||
scheduleType: ScheduleType;
|
||||
scheduledAt: string | null;
|
||||
cronExpr: string | null;
|
||||
timezone: string;
|
||||
nextRunAt: string | null;
|
||||
lastRunAt: string | null;
|
||||
targetCount: number;
|
||||
sentCount: number;
|
||||
readCount: number;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CampaignStats {
|
||||
targetCount: number;
|
||||
sentCount: number;
|
||||
readCount: number;
|
||||
readRate: number;
|
||||
executions: any[];
|
||||
}
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
return { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
const BASE = '/api/proxy/api/v1/notifications/campaigns';
|
||||
|
||||
async function listCampaigns(params: { status?: string; limit?: number; offset?: number }) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.status) q.set('status', params.status);
|
||||
if (params.limit != null) q.set('limit', String(params.limit));
|
||||
if (params.offset != null) q.set('offset', String(params.offset));
|
||||
const res = await fetch(`${BASE}?${q}`, { headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json() as Promise<{ items: Campaign[]; total: number }>;
|
||||
}
|
||||
|
||||
async function createCampaign(dto: any) {
|
||||
const res = await fetch(BASE, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(dto) });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function updateCampaign(id: string, dto: any) {
|
||||
const res = await fetch(`${BASE}/${id}`, { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify(dto) });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function scheduleCampaign(id: string, dto: { scheduledAt?: string; cronExpr?: string; timezone?: string }) {
|
||||
const res = await fetch(`${BASE}/${id}/schedule`, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(dto) });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function cancelCampaign(id: string) {
|
||||
const res = await fetch(`${BASE}/${id}/cancel`, { method: 'POST', headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function deleteCampaign(id: string) {
|
||||
const res = await fetch(`${BASE}/${id}`, { method: 'DELETE', headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
async function getCampaignStats(id: string): Promise<CampaignStats> {
|
||||
const res = await fetch(`${BASE}/${id}/stats`, { headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ---------- Status badge ---------- */
|
||||
|
||||
const STATUS_STYLE: Record<CampaignStatus, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-600',
|
||||
SCHEDULED: 'bg-blue-100 text-blue-700',
|
||||
RUNNING: 'bg-yellow-100 text-yellow-700',
|
||||
COMPLETED: 'bg-green-100 text-green-700',
|
||||
CANCELLED: 'bg-gray-100 text-gray-500',
|
||||
FAILED: 'bg-red-100 text-red-700',
|
||||
};
|
||||
const STATUS_LABEL: Record<CampaignStatus, string> = {
|
||||
DRAFT: '草稿', SCHEDULED: '已排期', RUNNING: '执行中',
|
||||
COMPLETED: '已完成', CANCELLED: '已取消', FAILED: '失败',
|
||||
};
|
||||
|
||||
/* ---------- Stats Modal ---------- */
|
||||
|
||||
function StatsModal({ campaignId, name, onClose }: { campaignId: string; name: string; onClose: () => void }) {
|
||||
const [stats, setStats] = useState<CampaignStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getCampaignStats(campaignId)
|
||||
.then(setStats)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [campaignId]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-card w-full max-w-lg rounded-xl shadow-xl border flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-base font-semibold">{name} — 数据统计</h2>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{loading ? (
|
||||
<p className="text-center text-muted-foreground">加载中…</p>
|
||||
) : stats ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: '目标人数', value: stats.targetCount },
|
||||
{ label: '已发送', value: stats.sentCount },
|
||||
{ label: '已阅读', value: stats.readCount },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-muted/50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold">{item.value.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{item.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-primary">{stats.readRate}%</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">阅读率</p>
|
||||
</div>
|
||||
{stats.executions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-2 text-muted-foreground">最近执行记录</p>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{stats.executions.map((ex: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs px-2 py-1 bg-muted/30 rounded">
|
||||
<span>{new Date(ex.started_at).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' })}</span>
|
||||
<span className={ex.status === 'COMPLETED' ? 'text-green-600' : ex.status === 'FAILED' ? 'text-red-600' : 'text-yellow-600'}>
|
||||
{ex.status}
|
||||
</span>
|
||||
<span>{ex.sent_count} 发送</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center">暂无数据</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Campaign Modal ---------- */
|
||||
|
||||
function CampaignModal({
|
||||
initial,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
initial?: Campaign;
|
||||
onSave: (dto: any, id?: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [description, setDescription] = useState(initial?.description ?? '');
|
||||
const [notificationId, setNotificationId] = useState(initial?.notificationId ?? '');
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleType>(initial?.scheduleType ?? 'ONCE');
|
||||
const [scheduledAt, setScheduledAt] = useState(
|
||||
initial?.scheduledAt ? initial.scheduledAt.slice(0, 16) : '',
|
||||
);
|
||||
const [cronExpr, setCronExpr] = useState(initial?.cronExpr ?? '');
|
||||
const [timezone, setTimezone] = useState(initial?.timezone ?? 'Asia/Shanghai');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) { setError('活动名称不能为空'); return; }
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
notificationId: notificationId.trim() || undefined,
|
||||
scheduleType,
|
||||
scheduledAt: scheduleType === 'ONCE' && scheduledAt ? new Date(scheduledAt).toISOString() : null,
|
||||
cronExpr: scheduleType === 'RECURRING' ? cronExpr.trim() : null,
|
||||
timezone,
|
||||
}, initial?.id);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-card w-full max-w-lg rounded-xl shadow-xl border flex flex-col max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-base font-semibold">{initial ? '编辑活动' : '新建推送活动'}</h2>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">活动名称 *</label>
|
||||
<input className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary"
|
||||
value={name} onChange={(e) => setName(e.target.value)} maxLength={200} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">描述(可选)</label>
|
||||
<textarea className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
rows={2} value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">关联通知模板 ID(可选)</label>
|
||||
<input className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-primary"
|
||||
value={notificationId} onChange={(e) => setNotificationId(e.target.value)}
|
||||
placeholder="notification UUID" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">调度类型</label>
|
||||
<div className="flex gap-4">
|
||||
{(['ONCE', 'RECURRING'] as ScheduleType[]).map((t) => (
|
||||
<label key={t} className="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input type="radio" value={t} checked={scheduleType === t} onChange={() => setScheduleType(t)} />
|
||||
{t === 'ONCE' ? '单次执行' : '循环执行(Cron)'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleType === 'ONCE' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">执行时间</label>
|
||||
<input type="datetime-local" className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'RECURRING' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Cron 表达式</label>
|
||||
<input className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-primary"
|
||||
value={cronExpr} onChange={(e) => setCronExpr(e.target.value)}
|
||||
placeholder="0 9 * * 1 (每周一 09:00)" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">时区</label>
|
||||
<input className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary"
|
||||
value={timezone} onChange={(e) => setTimezone(e.target.value)}
|
||||
placeholder="Asia/Shanghai" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={saving}
|
||||
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50">
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Main page ---------- */
|
||||
|
||||
const STATUS_OPTIONS: CampaignStatus[] = ['DRAFT', 'SCHEDULED', 'RUNNING', 'COMPLETED', 'CANCELLED', 'FAILED'];
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const [items, setItems] = useState<Campaign[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const limit = 20;
|
||||
const [modal, setModal] = useState<{ mode: 'create' } | { mode: 'edit'; item: Campaign } | null>(null);
|
||||
const [statsId, setStatsId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listCampaigns({ status: filterStatus || undefined, limit, offset });
|
||||
setItems(result.items);
|
||||
setTotal(result.total);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}, [filterStatus, offset]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async (dto: any, id?: string) => {
|
||||
if (id) {
|
||||
await updateCampaign(id, dto);
|
||||
} else {
|
||||
const campaign = await createCampaign(dto);
|
||||
// Auto-schedule if scheduledAt provided
|
||||
if (dto.scheduledAt || dto.cronExpr) {
|
||||
await scheduleCampaign(campaign.id, { scheduledAt: dto.scheduledAt, cronExpr: dto.cronExpr, timezone: dto.timezone });
|
||||
}
|
||||
}
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleSchedule = async (item: Campaign) => {
|
||||
if (!item.scheduledAt && !item.cronExpr) {
|
||||
alert('请先设置执行时间或 Cron 表达式');
|
||||
return;
|
||||
}
|
||||
await scheduleCampaign(item.id, {
|
||||
scheduledAt: item.scheduledAt ?? undefined,
|
||||
cronExpr: item.cronExpr ?? undefined,
|
||||
timezone: item.timezone,
|
||||
});
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleCancel = async (item: Campaign) => {
|
||||
if (!confirm(`确认取消活动「${item.name}」?`)) return;
|
||||
await cancelCampaign(item.id);
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDelete = async (item: Campaign) => {
|
||||
if (!confirm(`确认删除活动「${item.name}」?`)) return;
|
||||
setDeleting(item.id);
|
||||
try { await deleteCampaign(item.id); await load(); }
|
||||
finally { setDeleting(null); }
|
||||
};
|
||||
|
||||
const statsItem = items.find((i) => i.id === statsId);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Megaphone className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-xl font-semibold">推送活动管理</h1>
|
||||
<span className="text-xs text-muted-foreground ml-1">(批量定时推送)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setOffset(0); }}>
|
||||
<option value="">全部状态</option>
|
||||
{STATUS_OPTIONS.map((s) => <option key={s} value={s}>{STATUS_LABEL[s]}</option>)}
|
||||
</select>
|
||||
<button onClick={load} disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border hover:bg-accent transition-colors">
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />刷新
|
||||
</button>
|
||||
<button onClick={() => setModal({ mode: 'create' })}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" />新建活动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto rounded-lg border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground w-48">活动名称</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">状态</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">调度</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">下次执行</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">发送/阅读</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading && items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-muted-foreground">加载中…</td></tr>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-muted-foreground">暂无活动</td></tr>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-medium max-w-[12rem] truncate" title={item.name}>{item.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn('px-2 py-0.5 rounded text-xs font-medium', STATUS_STYLE[item.status])}>
|
||||
{STATUS_LABEL[item.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">
|
||||
{item.scheduleType === 'ONCE' ? '单次' : `循环: ${item.cronExpr ?? '—'}`}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">
|
||||
{item.nextRunAt ? new Date(item.nextRunAt).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' }) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">
|
||||
{item.sentCount} / {item.readCount}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{/* Stats */}
|
||||
<button onClick={() => setStatsId(item.id)}
|
||||
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors" title="查看数据">
|
||||
<BarChart2 className="h-4 w-4" />
|
||||
</button>
|
||||
{/* Schedule (DRAFT only) */}
|
||||
{item.status === 'DRAFT' && (
|
||||
<button onClick={() => handleSchedule(item)}
|
||||
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors" title="排期">
|
||||
<Play className="h-4 w-4 text-blue-600" />
|
||||
</button>
|
||||
)}
|
||||
{/* Cancel (SCHEDULED only) */}
|
||||
{item.status === 'SCHEDULED' && (
|
||||
<button onClick={() => handleCancel(item)}
|
||||
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors" title="取消">
|
||||
<Ban className="h-4 w-4 text-orange-500" />
|
||||
</button>
|
||||
)}
|
||||
{/* Edit */}
|
||||
{['DRAFT', 'SCHEDULED'].includes(item.status) && (
|
||||
<button onClick={() => setModal({ mode: 'edit', item })}
|
||||
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* Delete */}
|
||||
<button onClick={() => handleDelete(item)} disabled={deleting === item.id}
|
||||
className="p-1.5 rounded hover:bg-red-50 text-muted-foreground hover:text-red-600 transition-colors disabled:opacity-50">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{total > limit && (
|
||||
<div className="flex items-center justify-between mt-4 text-sm text-muted-foreground">
|
||||
<span>共 {total} 条</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
className="px-3 py-1.5 rounded border hover:bg-accent disabled:opacity-40 transition-colors">上一页</button>
|
||||
<button disabled={offset + limit >= total} onClick={() => setOffset(offset + limit)}
|
||||
className="px-3 py-1.5 rounded border hover:bg-accent disabled:opacity-40 transition-colors">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<CampaignModal
|
||||
initial={modal.mode === 'edit' ? modal.item : undefined}
|
||||
onSave={handleSave}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{statsId && statsItem && (
|
||||
<StatsModal campaignId={statsId} name={statsItem.name} onClose={() => setStatsId(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Radio, Plus, RefreshCw, Pencil, Trash2, X, Shield } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface NotificationChannel {
|
||||
channelKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isMandatory: boolean;
|
||||
isEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
return { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
async function listChannels(): Promise<NotificationChannel[]> {
|
||||
const res = await fetch('/api/proxy/api/v1/notifications/channels', { headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function createChannel(dto: { channelKey: string; name: string; description?: string; isMandatory?: boolean }) {
|
||||
const res = await fetch('/api/proxy/api/v1/notifications/channels', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(dto),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function updateChannel(key: string, dto: { name?: string; description?: string; isMandatory?: boolean; isEnabled?: boolean }) {
|
||||
const res = await fetch(`/api/proxy/api/v1/notifications/channels/${key}`, {
|
||||
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify(dto),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function deleteChannel(key: string) {
|
||||
const res = await fetch(`/api/proxy/api/v1/notifications/channels/${key}`, {
|
||||
method: 'DELETE', headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
|
||||
function ChannelModal({
|
||||
initial,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
initial?: NotificationChannel;
|
||||
onSave: (data: any, key?: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [channelKey, setChannelKey] = useState(initial?.channelKey ?? '');
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [description, setDescription] = useState(initial?.description ?? '');
|
||||
const [isMandatory, setIsMandatory] = useState(initial?.isMandatory ?? false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) { setError('名称不能为空'); return; }
|
||||
if (!initial && !channelKey.match(/^[a-z0-9_]+$/)) {
|
||||
setError('频道 key 只能包含小写字母、数字和下划线');
|
||||
return;
|
||||
}
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await onSave(
|
||||
{ channelKey, name: name.trim(), description: description.trim() || undefined, isMandatory },
|
||||
initial?.channelKey,
|
||||
);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-card w-full max-w-md rounded-xl shadow-xl border flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-base font-semibold">{initial ? '编辑频道' : '新建频道'}</h2>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{!initial && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">频道 Key *</label>
|
||||
<input
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-primary"
|
||||
value={channelKey}
|
||||
onChange={(e) => setChannelKey(e.target.value.toLowerCase())}
|
||||
placeholder="如: marketing, billing"
|
||||
maxLength={50}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">只能包含小写字母、数字和下划线,创建后不可更改</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">显示名称 *</label>
|
||||
<input
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="如: 营销推广"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">描述(可选)</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="该频道的用途说明"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isMandatory}
|
||||
onChange={(e) => setIsMandatory(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium">强制频道(用户无法关闭)</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground -mt-2">适用于安全告警、重要系统通知等必要信息。</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors">取消</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Main page ---------- */
|
||||
|
||||
export default function NotificationChannelsPage() {
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ mode: 'create' } | { mode: 'edit'; ch: NotificationChannel } | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setChannels(await listChannels()); }
|
||||
catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async (data: any, key?: string) => {
|
||||
if (key) {
|
||||
await updateChannel(key, data);
|
||||
} else {
|
||||
await createChannel(data);
|
||||
}
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDelete = async (ch: NotificationChannel) => {
|
||||
if (ch.isMandatory) { alert('强制频道不可删除'); return; }
|
||||
if (!confirm(`确认删除频道「${ch.name}」?`)) return;
|
||||
setDeleting(ch.channelKey);
|
||||
try { await deleteChannel(ch.channelKey); await load(); }
|
||||
finally { setDeleting(null); }
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (ch: NotificationChannel) => {
|
||||
await updateChannel(ch.channelKey, { isEnabled: !ch.isEnabled });
|
||||
await load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-xl font-semibold">通知频道管理</h1>
|
||||
<span className="text-xs text-muted-foreground ml-1">(用户可按频道订阅/取消通知)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={load} disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border hover:bg-accent transition-colors"
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'create' })}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />新建频道
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto rounded-lg border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Key</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">名称</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">描述</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">类型</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">状态</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading && channels.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-muted-foreground">加载中…</td></tr>
|
||||
)}
|
||||
{!loading && channels.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-muted-foreground">暂无频道</td></tr>
|
||||
)}
|
||||
{channels.map((ch) => (
|
||||
<tr key={ch.channelKey} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{ch.channelKey}</td>
|
||||
<td className="px-4 py-3 font-medium">{ch.name}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-xs truncate">{ch.description ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{ch.isMandatory ? (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-700 bg-amber-50 px-2 py-0.5 rounded font-medium w-fit">
|
||||
<Shield className="h-3 w-3" />强制
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">可选</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(ch)}
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded text-xs font-medium cursor-pointer transition-colors',
|
||||
ch.isEnabled ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-gray-100 text-gray-500 hover:bg-gray-200',
|
||||
)}
|
||||
>
|
||||
{ch.isEnabled ? '已启用' : '已禁用'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'edit', ch })}
|
||||
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(ch)}
|
||||
disabled={!!deleting || ch.isMandatory}
|
||||
className="p-1.5 rounded hover:bg-red-50 text-muted-foreground hover:text-red-600 transition-colors disabled:opacity-30"
|
||||
title={ch.isMandatory ? '强制频道不可删除' : '删除'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<ChannelModal
|
||||
initial={modal.mode === 'edit' ? modal.ch : undefined}
|
||||
onSave={handleSave}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,705 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
NotificationItem,
|
||||
NotificationType,
|
||||
NotificationPriority,
|
||||
TargetType,
|
||||
TagLogic,
|
||||
CreateNotificationPayload,
|
||||
NOTIFICATION_TYPE_OPTIONS,
|
||||
NOTIFICATION_PRIORITY_OPTIONS,
|
||||
TARGET_TYPE_OPTIONS,
|
||||
PLAN_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
} from '@/domain/entities/notification';
|
||||
import {
|
||||
listAdminNotifications,
|
||||
createNotification,
|
||||
updateNotification,
|
||||
deleteNotification,
|
||||
toggleNotification,
|
||||
} from '@/infrastructure/repositories/api-notification.repository';
|
||||
import { listTenantTags } from '@/infrastructure/repositories/api-tenant-tag.repository';
|
||||
import { TenantTag } from '@/domain/entities/tenant-tag';
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
|
||||
function typeBadge(type: NotificationType) {
|
||||
const opt = NOTIFICATION_TYPE_OPTIONS.find((o) => o.value === type);
|
||||
return (
|
||||
<span className={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', opt?.color ?? 'bg-gray-100 text-gray-700')}>
|
||||
{opt?.label ?? type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function targetLabel(item: NotificationItem) {
|
||||
switch (item.targetType) {
|
||||
case 'ALL': return '全部租户';
|
||||
case 'SPECIFIC_TENANTS': return `指定租户 (${item.tenantIds?.length ?? 0})`;
|
||||
case 'SPECIFIC_USERS': return `指定用户 (${item.userIds?.length ?? 0})`;
|
||||
case 'BY_TENANT_TAG': return `按标签 (${item.targetTagIds?.length ?? 0})`;
|
||||
case 'BY_PLAN': return `套餐: ${item.targetPlans?.join(', ') ?? '—'}`;
|
||||
case 'BY_TENANT_STATUS': return `状态: ${item.targetStatuses?.join(', ') ?? '—'}`;
|
||||
case 'BY_SEGMENT': return `人群包: ${item.targetSegment ?? '—'}`;
|
||||
default: return item.targetType;
|
||||
}
|
||||
}
|
||||
|
||||
function priorityLabel(p: NotificationPriority) {
|
||||
const map: Record<NotificationPriority, string> = {
|
||||
LOW: '低', NORMAL: '普通', HIGH: '高', URGENT: '紧急',
|
||||
};
|
||||
return map[p] ?? p;
|
||||
}
|
||||
|
||||
function formatDate(s: string | null) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
/* ---------- form defaults ---------- */
|
||||
|
||||
const EMPTY_FORM: CreateNotificationPayload = {
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'ANNOUNCEMENT',
|
||||
priority: 'NORMAL',
|
||||
targetType: 'ALL',
|
||||
tenantIds: [],
|
||||
userIds: [],
|
||||
targetTagIds: [],
|
||||
targetTagLogic: 'ANY',
|
||||
targetPlans: [],
|
||||
targetStatuses: [],
|
||||
targetSegment: null,
|
||||
channelKey: null,
|
||||
imageUrl: null,
|
||||
linkUrl: null,
|
||||
requiresForceRead: false,
|
||||
isEnabled: true,
|
||||
publishedAt: null,
|
||||
expiresAt: null,
|
||||
};
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
|
||||
function NotificationModal({
|
||||
initial,
|
||||
allTags,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
initial: Partial<CreateNotificationPayload> & { id?: string };
|
||||
allTags: TenantTag[];
|
||||
onSave: (payload: CreateNotificationPayload, id?: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<CreateNotificationPayload>({
|
||||
...EMPTY_FORM,
|
||||
...initial,
|
||||
});
|
||||
const [tenantIdsRaw, setTenantIdsRaw] = useState((initial.tenantIds ?? []).join('\n'));
|
||||
const [userIdsRaw, setUserIdsRaw] = useState((initial.userIds ?? []).join('\n'));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const set = <K extends keyof CreateNotificationPayload>(k: K, v: CreateNotificationPayload[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const togglePlan = (plan: string) => {
|
||||
const cur = form.targetPlans ?? [];
|
||||
set('targetPlans', cur.includes(plan) ? cur.filter((p) => p !== plan) : [...cur, plan]);
|
||||
};
|
||||
|
||||
const toggleStatus = (status: string) => {
|
||||
const cur = form.targetStatuses ?? [];
|
||||
set('targetStatuses', cur.includes(status) ? cur.filter((s) => s !== status) : [...cur, status]);
|
||||
};
|
||||
|
||||
const toggleTag = (tagId: string) => {
|
||||
const cur = form.targetTagIds ?? [];
|
||||
set('targetTagIds', cur.includes(tagId) ? cur.filter((t) => t !== tagId) : [...cur, tagId]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.title.trim()) { setError('标题不能为空'); return; }
|
||||
if (!form.content.trim()) { setError('内容不能为空'); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const tenantIds = tenantIdsRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
||||
const userIds = userIdsRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
||||
await onSave({ ...form, tenantIds, userIds }, initial.id);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-card w-full max-w-2xl rounded-xl shadow-xl border flex flex-col max-h-[90vh]">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-base font-semibold">{initial.id ? '编辑通知' : '新建通知'}</h2>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
{/* title */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">标题 *</label>
|
||||
<input
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary"
|
||||
value={form.title}
|
||||
onChange={(e) => set('title', e.target.value)}
|
||||
maxLength={200}
|
||||
placeholder="通知标题"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* content */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">内容 *</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
rows={5}
|
||||
value={form.content}
|
||||
onChange={(e) => set('content', e.target.value)}
|
||||
placeholder="通知正文内容"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* type + priority */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">类型</label>
|
||||
<select
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={form.type}
|
||||
onChange={(e) => set('type', e.target.value as NotificationType)}
|
||||
>
|
||||
{NOTIFICATION_TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">优先级</label>
|
||||
<select
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={form.priority}
|
||||
onChange={(e) => set('priority', e.target.value as NotificationPriority)}
|
||||
>
|
||||
{NOTIFICATION_PRIORITY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* target type */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">推送对象</label>
|
||||
<select
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={form.targetType}
|
||||
onChange={(e) => set('targetType', e.target.value as TargetType)}
|
||||
>
|
||||
{TARGET_TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label} — {o.desc}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* SPECIFIC_TENANTS */}
|
||||
{form.targetType === 'SPECIFIC_TENANTS' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">指定租户 ID(每行一个)</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
rows={4}
|
||||
value={tenantIdsRaw}
|
||||
onChange={(e) => setTenantIdsRaw(e.target.value)}
|
||||
placeholder="tenant-uuid-1 tenant-uuid-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SPECIFIC_USERS */}
|
||||
{form.targetType === 'SPECIFIC_USERS' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">指定用户 ID(每行一个)</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
rows={4}
|
||||
value={userIdsRaw}
|
||||
onChange={(e) => setUserIdsRaw(e.target.value)}
|
||||
placeholder="user-uuid-1 user-uuid-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BY_TENANT_TAG */}
|
||||
{form.targetType === 'BY_TENANT_TAG' && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">标签逻辑</label>
|
||||
<div className="flex gap-4">
|
||||
{(['ANY', 'ALL'] as TagLogic[]).map((logic) => (
|
||||
<label key={logic} className="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
value={logic}
|
||||
checked={form.targetTagLogic === logic}
|
||||
onChange={() => set('targetTagLogic', logic)}
|
||||
/>
|
||||
{logic === 'ANY' ? '任一标签(OR)' : '全部标签(AND)'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">选择标签</label>
|
||||
{allTags.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">暂无标签,请先在「租户标签」页面创建。</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => {
|
||||
const selected = (form.targetTagIds ?? []).includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-xs font-medium border transition-colors',
|
||||
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:border-primary',
|
||||
)}
|
||||
style={!selected ? { borderColor: tag.color, color: tag.color } : {}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BY_PLAN */}
|
||||
{form.targetType === 'BY_PLAN' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">选择套餐</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PLAN_OPTIONS.map((plan) => {
|
||||
const selected = (form.targetPlans ?? []).includes(plan);
|
||||
return (
|
||||
<button
|
||||
key={plan}
|
||||
type="button"
|
||||
onClick={() => togglePlan(plan)}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-xs font-medium border transition-colors',
|
||||
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:border-primary',
|
||||
)}
|
||||
>
|
||||
{plan}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BY_TENANT_STATUS */}
|
||||
{form.targetType === 'BY_TENANT_STATUS' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">选择租户状态</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUS_OPTIONS.map((status) => {
|
||||
const selected = (form.targetStatuses ?? []).includes(status);
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => toggleStatus(status)}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-xs font-medium border transition-colors',
|
||||
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:border-primary',
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BY_SEGMENT */}
|
||||
{form.targetType === 'BY_SEGMENT' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">人群包 Key</label>
|
||||
<input
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary"
|
||||
value={form.targetSegment ?? ''}
|
||||
onChange={(e) => set('targetSegment', e.target.value || null)}
|
||||
placeholder="例如: trial_ending_soon"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* image / link */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">图片链接(可选)</label>
|
||||
<input
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={form.imageUrl ?? ''}
|
||||
onChange={(e) => set('imageUrl', e.target.value || null)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">跳转链接(可选)</label>
|
||||
<input
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={form.linkUrl ?? ''}
|
||||
onChange={(e) => set('linkUrl', e.target.value || null)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* published / expires */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">发布时间(空=立即)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={form.publishedAt ? form.publishedAt.slice(0, 16) : ''}
|
||||
onChange={(e) => set('publishedAt', e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">过期时间(空=永不)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={form.expiresAt ? form.expiresAt.slice(0, 16) : ''}
|
||||
onChange={(e) => set('expiresAt', e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* toggles */}
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.requiresForceRead}
|
||||
onChange={(e) => set('requiresForceRead', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">强制阅读(用户打开 App 必须确认)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isEnabled}
|
||||
onChange={(e) => set('isEnabled', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">立即启用</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Main page ---------- */
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [items, setItems] = useState<NotificationItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const [filterTarget, setFilterTarget] = useState('');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const limit = 20;
|
||||
const [allTags, setAllTags] = useState<TenantTag[]>([]);
|
||||
|
||||
const [modal, setModal] = useState<
|
||||
{ mode: 'create' } | { mode: 'edit'; item: NotificationItem } | null
|
||||
>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listTenantTags().then(setAllTags).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listAdminNotifications({
|
||||
type: filterType || undefined,
|
||||
targetType: filterTarget || undefined,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
setItems(result.items);
|
||||
setTotal(result.total);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterType, filterTarget, offset]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async (payload: CreateNotificationPayload, id?: string) => {
|
||||
if (id) {
|
||||
await updateNotification(id, payload);
|
||||
} else {
|
||||
await createNotification(payload);
|
||||
}
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleToggle = async (item: NotificationItem) => {
|
||||
await toggleNotification(item.id, !item.isEnabled);
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确认删除该通知?')) return;
|
||||
setDeleting(id);
|
||||
try {
|
||||
await deleteNotification(id);
|
||||
await load();
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 p-6">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-xl font-semibold">站内消息管理</h1>
|
||||
<span className="text-xs text-muted-foreground ml-1">(仅平台管理员可见)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
value={filterType}
|
||||
onChange={(e) => { setFilterType(e.target.value); setOffset(0); }}
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{NOTIFICATION_TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
value={filterTarget}
|
||||
onChange={(e) => { setFilterTarget(e.target.value); setOffset(0); }}
|
||||
>
|
||||
<option value="">全部对象</option>
|
||||
{TARGET_TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border hover:bg-accent transition-colors"
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'create' })}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新建通知
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* table */}
|
||||
<div className="flex-1 min-h-0 overflow-auto rounded-lg border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground w-48">标题</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">类型</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">优先级</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">推送对象</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">标记</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">发布时间</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">过期时间</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">状态</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-center py-12 text-muted-foreground">加载中…</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-center py-12 text-muted-foreground">暂无通知</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 max-w-[12rem] truncate font-medium" title={item.title}>
|
||||
{item.title}
|
||||
</td>
|
||||
<td className="px-4 py-3">{typeBadge(item.type)}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{priorityLabel(item.priority)}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{targetLabel(item)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.requiresForceRead && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[11px] bg-red-100 text-red-700 font-medium">
|
||||
强制阅读
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{formatDate(item.publishedAt)}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{formatDate(item.expiresAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded text-xs font-medium',
|
||||
item.isEnabled
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500',
|
||||
)}
|
||||
>
|
||||
{item.isEnabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleToggle(item)}
|
||||
title={item.isEnabled ? '禁用' : '启用'}
|
||||
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{item.isEnabled
|
||||
? <ToggleRight className="h-4 w-4 text-green-600" />
|
||||
: <ToggleLeft className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'edit', item })}
|
||||
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
disabled={deleting === item.id}
|
||||
className="p-1.5 rounded hover:bg-red-50 text-muted-foreground hover:text-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* pagination */}
|
||||
{total > limit && (
|
||||
<div className="flex items-center justify-between mt-4 text-sm text-muted-foreground">
|
||||
<span>共 {total} 条</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
className="px-3 py-1.5 rounded border hover:bg-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
disabled={offset + limit >= total}
|
||||
onClick={() => setOffset(offset + limit)}
|
||||
className="px-3 py-1.5 rounded border hover:bg-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* modal */}
|
||||
{modal && (
|
||||
<NotificationModal
|
||||
initial={
|
||||
modal.mode === 'edit'
|
||||
? { ...modal.item, id: modal.item.id }
|
||||
: {}
|
||||
}
|
||||
allTags={allTags}
|
||||
onSave={handleSave}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Users2, RefreshCw, Trash2, X, ChevronRight, Upload } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SegmentInfo {
|
||||
segmentKey: string;
|
||||
memberCount: number;
|
||||
syncedAt: string | null;
|
||||
}
|
||||
|
||||
interface SegmentMember {
|
||||
tenantId: string;
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
return { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
const BASE = '/api/proxy/api/v1/notifications/segments';
|
||||
|
||||
async function listSegments(): Promise<SegmentInfo[]> {
|
||||
const res = await fetch(BASE, { headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function getSegmentMembers(key: string, limit = 50, offset = 0): Promise<{ items: SegmentMember[]; total: number }> {
|
||||
const res = await fetch(`${BASE}/${key}/members?limit=${limit}&offset=${offset}`, { headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function syncSegment(key: string, tenantIds: string[]): Promise<void> {
|
||||
const res = await fetch(`${BASE}/${key}/sync`, {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ tenantIds }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
async function addToSegment(key: string, tenantIds: string[]): Promise<void> {
|
||||
const res = await fetch(`${BASE}/${key}/add`, {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ tenantIds }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
async function removeFromSegment(key: string, tenantIds: string[]): Promise<void> {
|
||||
const res = await fetch(`${BASE}/${key}/remove`, {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ tenantIds }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
async function deleteSegment(key: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/${key}`, { method: 'DELETE', headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
/* ---------- Segment detail panel ---------- */
|
||||
|
||||
function SegmentDetail({
|
||||
segmentKey,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
segmentKey: string;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [members, setMembers] = useState<SegmentMember[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
const [addRaw, setAddRaw] = useState('');
|
||||
const [syncRaw, setSyncRaw] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getSegmentMembers(segmentKey, limit, offset);
|
||||
setMembers(result.items);
|
||||
setTotal(result.total);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}, [segmentKey, offset]);
|
||||
|
||||
useEffect(() => { loadMembers(); }, [loadMembers]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const ids = addRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await addToSegment(segmentKey, ids);
|
||||
setAddRaw('');
|
||||
await loadMembers();
|
||||
onRefresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
const ids = syncRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
||||
if (!confirm(`确认用 ${ids.length} 个租户 ID 替换「${segmentKey}」的全部成员?`)) return;
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await syncSegment(segmentKey, ids);
|
||||
setSyncRaw('');
|
||||
await loadMembers();
|
||||
onRefresh();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleRemove = async (tenantId: string) => {
|
||||
await removeFromSegment(segmentKey, [tenantId]);
|
||||
await loadMembers();
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-card w-full max-w-2xl rounded-xl shadow-xl border flex flex-col max-h-[85vh]">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold font-mono">{segmentKey}</h2>
|
||||
<p className="text-xs text-muted-foreground">共 {total} 个成员租户</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
{/* Add tenants */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">新增租户(每行一个 ID)</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
rows={3} value={addRaw} onChange={(e) => setAddRaw(e.target.value)}
|
||||
placeholder="tenant-uuid-1 tenant-uuid-2"
|
||||
/>
|
||||
<button onClick={handleAdd} disabled={saving || !addRaw.trim()}
|
||||
className="mt-2 flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors">
|
||||
添加成员
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bulk sync */}
|
||||
<div className="border rounded-lg p-3 bg-muted/30">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Upload className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<label className="text-xs font-medium">全量替换(ETL 同步)</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
rows={3} value={syncRaw} onChange={(e) => setSyncRaw(e.target.value)}
|
||||
placeholder="全量租户 ID,将替换现有成员"
|
||||
/>
|
||||
<button onClick={handleSync} disabled={saving || !syncRaw.trim()}
|
||||
className="mt-2 flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-orange-600 text-white hover:bg-orange-700 disabled:opacity-50 transition-colors">
|
||||
全量替换
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Members list */}
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-2 text-muted-foreground">当前成员 ({total})</p>
|
||||
<div className="border rounded-lg divide-y max-h-60 overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="text-center py-4 text-sm text-muted-foreground">加载中…</p>
|
||||
) : members.length === 0 ? (
|
||||
<p className="text-center py-4 text-sm text-muted-foreground">暂无成员</p>
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<div key={m.tenantId} className="flex items-center justify-between px-3 py-2 hover:bg-muted/30">
|
||||
<span className="text-xs font-mono text-muted-foreground">{m.tenantId}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(m.syncedAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<button onClick={() => handleRemove(m.tenantId)}
|
||||
className="p-1 rounded hover:bg-red-50 text-muted-foreground hover:text-red-600 transition-colors">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{total > limit && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
className="px-3 py-1 text-xs rounded border hover:bg-accent disabled:opacity-40">上一页</button>
|
||||
<button disabled={offset + limit >= total} onClick={() => setOffset(offset + limit)}
|
||||
className="px-3 py-1 text-xs rounded border hover:bg-accent disabled:opacity-40">下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Main page ---------- */
|
||||
|
||||
export default function SegmentsPage() {
|
||||
const [segments, setSegments] = useState<SegmentInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setSegments(await listSegments()); }
|
||||
catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
if (!confirm(`确认删除人群包「${key}」?`)) return;
|
||||
setDeleting(key);
|
||||
try { await deleteSegment(key); await load(); }
|
||||
finally { setDeleting(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users2 className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-xl font-semibold">人群包管理</h1>
|
||||
<span className="text-xs text-muted-foreground ml-1">(BY_SEGMENT 精准推送)</span>
|
||||
</div>
|
||||
<button onClick={load} disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border hover:bg-accent transition-colors">
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 rounded-lg text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>说明:</strong>人群包是一组租户 ID 集合,可用于「按人群包」精准推送通知。
|
||||
可通过 API 或此界面手动管理,也可由外部 ETL 作业定期同步。
|
||||
在通知表单中,选择「按人群包」并输入对应的 segment key 即可定向推送。
|
||||
</div>
|
||||
|
||||
{loading && segments.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">加载中…</p>
|
||||
) : segments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground gap-3">
|
||||
<Users2 className="h-12 w-12 opacity-20" />
|
||||
<p className="text-sm">暂无人群包。点击某个分组名称或通过 API 的 <code>/sync</code> 接口创建。</p>
|
||||
<p className="text-xs">例:<code>POST /api/v1/notifications/segments/trial_ending_soon/sync</code></p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-auto rounded-lg border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Segment Key</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">成员数</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">最近同步</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{segments.map((seg) => (
|
||||
<tr key={seg.segmentKey} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-sm font-medium">{seg.segmentKey}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 rounded bg-primary/10 text-primary text-xs font-medium">
|
||||
{seg.memberCount.toLocaleString()} 租户
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">
|
||||
{seg.syncedAt ? new Date(seg.syncedAt).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' }) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setSelectedKey(seg.segmentKey)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs hover:bg-accent text-muted-foreground hover:text-foreground transition-colors">
|
||||
管理 <ChevronRight className="h-3 w-3" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(seg.segmentKey)} disabled={deleting === seg.segmentKey}
|
||||
className="p-1.5 rounded hover:bg-red-50 text-muted-foreground hover:text-red-600 transition-colors disabled:opacity-50">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedKey && (
|
||||
<SegmentDetail
|
||||
segmentKey={selectedKey}
|
||||
onClose={() => setSelectedKey(null)}
|
||||
onRefresh={load}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Tags, Plus, RefreshCw, Pencil, Trash2, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TenantTag, CreateTenantTagDto, UpdateTenantTagDto } from '@/domain/entities/tenant-tag';
|
||||
import {
|
||||
listTenantTags,
|
||||
createTenantTag,
|
||||
updateTenantTag,
|
||||
deleteTenantTag,
|
||||
} from '@/infrastructure/repositories/api-tenant-tag.repository';
|
||||
|
||||
/* ---------- Color picker presets ---------- */
|
||||
|
||||
const COLOR_PRESETS = [
|
||||
'#6366F1', '#8B5CF6', '#EC4899', '#EF4444',
|
||||
'#F97316', '#EAB308', '#22C55E', '#14B8A6',
|
||||
'#3B82F6', '#64748B',
|
||||
];
|
||||
|
||||
/* ---------- Tag Modal ---------- */
|
||||
|
||||
function TagModal({
|
||||
initial,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
initial?: TenantTag;
|
||||
onSave: (dto: CreateTenantTagDto | UpdateTenantTagDto, id?: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [color, setColor] = useState(initial?.color ?? '#6366F1');
|
||||
const [description, setDescription] = useState(initial?.description ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) { setError('标签名不能为空'); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await onSave({ name: name.trim(), color, description: description.trim() || undefined }, initial?.id);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-card w-full max-w-md rounded-xl shadow-xl border flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-base font-semibold">{initial ? '编辑标签' : '新建标签'}</h2>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">标签名 *</label>
|
||||
<input
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={50}
|
||||
placeholder="例如: 高价值客户"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">标签颜色</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{COLOR_PRESETS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={cn(
|
||||
'w-7 h-7 rounded-full border-2 transition-all',
|
||||
color === c ? 'border-foreground scale-110' : 'border-transparent',
|
||||
)}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-8 w-12 rounded border cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-mono text-muted-foreground">{color}</span>
|
||||
<span
|
||||
className="px-3 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
预览
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">描述(可选)</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="该标签的用途说明"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Main page ---------- */
|
||||
|
||||
export default function TenantTagsPage() {
|
||||
const [tags, setTags] = useState<TenantTag[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ mode: 'create' } | { mode: 'edit'; tag: TenantTag } | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listTenantTags();
|
||||
setTags(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async (dto: CreateTenantTagDto | UpdateTenantTagDto, id?: string) => {
|
||||
if (id) {
|
||||
await updateTenantTag(id, dto as UpdateTenantTagDto);
|
||||
} else {
|
||||
await createTenantTag(dto as CreateTenantTagDto);
|
||||
}
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDelete = async (tag: TenantTag) => {
|
||||
if (!confirm(`确认删除标签「${tag.name}」?删除后相关租户的标签绑定也会清除。`)) return;
|
||||
setDeleting(tag.id);
|
||||
try {
|
||||
await deleteTenantTag(tag.id);
|
||||
await load();
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 p-6">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tags className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-xl font-semibold">租户标签管理</h1>
|
||||
<span className="text-xs text-muted-foreground ml-1">(用于精准推送分组)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border hover:bg-accent transition-colors"
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'create' })}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
新建标签
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* tag grid */}
|
||||
{loading && tags.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">加载中…</p>
|
||||
) : tags.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground gap-3">
|
||||
<Tags className="h-12 w-12 opacity-20" />
|
||||
<p className="text-sm">暂无标签,点击「新建标签」创建第一个标签</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="bg-card border rounded-xl p-4 flex flex-col gap-3 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
{/* tag header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-sm font-medium text-white"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'edit', tag })}
|
||||
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(tag)}
|
||||
disabled={deleting === tag.id}
|
||||
className="p-1.5 rounded hover:bg-red-50 text-muted-foreground hover:text-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
{tag.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{tag.description}</p>
|
||||
)}
|
||||
|
||||
{/* meta */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mt-auto">
|
||||
<span>
|
||||
{tag.tenantCount != null ? `${tag.tenantCount} 个租户` : '—'}
|
||||
</span>
|
||||
<span>{new Date(tag.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* modal */}
|
||||
{modal && (
|
||||
<TagModal
|
||||
initial={modal.mode === 'edit' ? modal.tag : undefined}
|
||||
onSave={handleSave}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
export type NotificationType =
|
||||
| 'SYSTEM'
|
||||
| 'MAINTENANCE'
|
||||
| 'FEATURE'
|
||||
| 'ANNOUNCEMENT'
|
||||
| 'BILLING'
|
||||
| 'SECURITY';
|
||||
|
||||
export type NotificationPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
||||
|
||||
export type TargetType =
|
||||
| 'ALL'
|
||||
| 'SPECIFIC_TENANTS'
|
||||
| 'SPECIFIC_USERS'
|
||||
| 'BY_TENANT_TAG'
|
||||
| 'BY_PLAN'
|
||||
| 'BY_TENANT_STATUS'
|
||||
| 'BY_SEGMENT';
|
||||
|
||||
export type TagLogic = 'ANY' | 'ALL';
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: NotificationType;
|
||||
priority: NotificationPriority;
|
||||
targetType: TargetType;
|
||||
tenantIds?: string[];
|
||||
userIds?: string[];
|
||||
targetTagIds?: string[] | null;
|
||||
targetTagLogic?: TagLogic;
|
||||
targetPlans?: string[] | null;
|
||||
targetStatuses?: string[] | null;
|
||||
targetSegment?: string | null;
|
||||
channelKey?: string | null;
|
||||
imageUrl: string | null;
|
||||
linkUrl: string | null;
|
||||
requiresForceRead: boolean;
|
||||
isEnabled: boolean;
|
||||
publishedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationListResult {
|
||||
items: NotificationItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateNotificationPayload {
|
||||
title: string;
|
||||
content: string;
|
||||
type: NotificationType;
|
||||
priority: NotificationPriority;
|
||||
targetType: TargetType;
|
||||
tenantIds?: string[];
|
||||
userIds?: string[];
|
||||
targetTagIds?: string[];
|
||||
targetTagLogic?: TagLogic;
|
||||
targetPlans?: string[];
|
||||
targetStatuses?: string[];
|
||||
targetSegment?: string | null;
|
||||
channelKey?: string | null;
|
||||
imageUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
requiresForceRead: boolean;
|
||||
isEnabled: boolean;
|
||||
publishedAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export const NOTIFICATION_TYPE_OPTIONS: { value: NotificationType; label: string; color: string }[] = [
|
||||
{ value: 'SYSTEM', label: '系统通知', color: 'bg-gray-100 text-gray-700' },
|
||||
{ value: 'MAINTENANCE', label: '维护通知', color: 'bg-orange-100 text-orange-700' },
|
||||
{ value: 'FEATURE', label: '新功能', color: 'bg-green-100 text-green-700' },
|
||||
{ value: 'ANNOUNCEMENT', label: '公告', color: 'bg-blue-100 text-blue-700' },
|
||||
{ value: 'BILLING', label: '账单通知', color: 'bg-purple-100 text-purple-700' },
|
||||
{ value: 'SECURITY', label: '安全通知', color: 'bg-red-100 text-red-700' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_PRIORITY_OPTIONS: { value: NotificationPriority; label: string }[] = [
|
||||
{ value: 'LOW', label: '低' },
|
||||
{ value: 'NORMAL', label: '普通' },
|
||||
{ value: 'HIGH', label: '高' },
|
||||
{ value: 'URGENT', label: '紧急' },
|
||||
];
|
||||
|
||||
export const TARGET_TYPE_OPTIONS: { value: TargetType; label: string; desc: string }[] = [
|
||||
{ value: 'ALL', label: '全部租户', desc: '发送给平台所有租户' },
|
||||
{ value: 'SPECIFIC_TENANTS', label: '指定租户', desc: '手动输入租户 ID 列表' },
|
||||
{ value: 'SPECIFIC_USERS', label: '指定用户', desc: '手动输入用户 ID 列表' },
|
||||
{ value: 'BY_TENANT_TAG', label: '按标签', desc: '推送给拥有指定标签的租户' },
|
||||
{ value: 'BY_PLAN', label: '按套餐', desc: '推送给特定订阅套餐的租户' },
|
||||
{ value: 'BY_TENANT_STATUS', label: '按租户状态', desc: '推送给特定状态的租户' },
|
||||
{ value: 'BY_SEGMENT', label: '按人群包', desc: '推送给预定义分群的租户' },
|
||||
];
|
||||
|
||||
export const PLAN_OPTIONS = ['free', 'pro', 'enterprise'];
|
||||
export const STATUS_OPTIONS = ['active', 'suspended', 'trial', 'expired'];
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
export interface TenantTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tenantCount?: number;
|
||||
}
|
||||
|
||||
export interface TenantTagAssignment {
|
||||
tenantId: string;
|
||||
tagId: string;
|
||||
taggedBy: string | null;
|
||||
taggedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateTenantTagDto {
|
||||
name: string;
|
||||
color?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTenantTagDto {
|
||||
name?: string;
|
||||
color?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
|
@ -33,6 +33,11 @@
|
|||
"billingInvoices": "Invoices",
|
||||
"appVersions": "App Versions",
|
||||
"referral": "Referrals",
|
||||
"tenantTags": "Tenant Tags",
|
||||
"notificationChannels": "Notification Channels",
|
||||
"campaigns": "Campaigns",
|
||||
"segments": "Audience Segments",
|
||||
"notifications": "Notifications",
|
||||
"tenants": "Tenants",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@
|
|||
"billingInvoices": "账单列表",
|
||||
"appVersions": "App 版本管理",
|
||||
"referral": "推荐管理",
|
||||
"tenantTags": "租户标签",
|
||||
"notificationChannels": "通知频道",
|
||||
"campaigns": "推送活动",
|
||||
"segments": "人群包",
|
||||
"notifications": "消息管理",
|
||||
"tenants": "租户",
|
||||
"users": "用户",
|
||||
"settings": "设置",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
NotificationItem,
|
||||
NotificationListResult,
|
||||
CreateNotificationPayload,
|
||||
} from '@/domain/entities/notification';
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
return {
|
||||
Authorization: `Bearer ${token ?? ''}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
const BASE = '/api/proxy';
|
||||
|
||||
export async function listAdminNotifications(params: {
|
||||
type?: string;
|
||||
targetType?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<NotificationListResult> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.type) q.set('type', params.type);
|
||||
if (params.targetType) q.set('targetType', params.targetType);
|
||||
if (params.limit != null) q.set('limit', String(params.limit));
|
||||
if (params.offset != null) q.set('offset', String(params.offset));
|
||||
const res = await fetch(`${BASE}/api/v1/notifications/admin?${q}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch notifications: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getAdminNotification(id: string): Promise<NotificationItem> {
|
||||
const res = await fetch(`${BASE}/api/v1/notifications/admin/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch notification: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createNotification(payload: CreateNotificationPayload): Promise<NotificationItem> {
|
||||
const res = await fetch(`${BASE}/api/v1/notifications/admin`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to create notification: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateNotification(
|
||||
id: string,
|
||||
payload: Partial<CreateNotificationPayload>,
|
||||
): Promise<NotificationItem> {
|
||||
const res = await fetch(`${BASE}/api/v1/notifications/admin/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to update notification: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteNotification(id: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/api/v1/notifications/admin/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to delete notification: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function toggleNotification(id: string, isEnabled: boolean): Promise<NotificationItem> {
|
||||
return updateNotification(id, { isEnabled });
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { TenantTag, CreateTenantTagDto, UpdateTenantTagDto } from '@/domain/entities/tenant-tag';
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('token');
|
||||
const tenantId = localStorage.getItem('tenantId');
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(tenantId ? { 'X-Tenant-Id': tenantId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const BASE = '/api/proxy';
|
||||
|
||||
export async function listTenantTags(): Promise<TenantTag[]> {
|
||||
const res = await fetch(`${BASE}/api/v1/admin/tenant-tags`, { headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch tenant tags: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createTenantTag(dto: CreateTenantTagDto): Promise<TenantTag> {
|
||||
const res = await fetch(`${BASE}/api/v1/admin/tenant-tags`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to create tenant tag: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateTenantTag(id: string, dto: UpdateTenantTagDto): Promise<TenantTag> {
|
||||
const res = await fetch(`${BASE}/api/v1/admin/tenant-tags/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to update tenant tag: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteTenantTag(id: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/api/v1/admin/tenant-tags/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to delete tenant tag: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function getTagTenants(tagId: string): Promise<{ tenantId: string; tenantName: string }[]> {
|
||||
const res = await fetch(`${BASE}/api/v1/admin/tenant-tags/${tagId}/tenants`, { headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch tag tenants: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getTenantTags(tenantId: string): Promise<TenantTag[]> {
|
||||
const res = await fetch(`${BASE}/api/v1/admin/tenant-tags/tenants/${tenantId}`, { headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch tenant tags: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function assignTagToTenants(tagId: string, tenantIds: string[]): Promise<void> {
|
||||
const res = await fetch(`${BASE}/api/v1/admin/tenant-tags/${tagId}/assign`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ tenantIds }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to assign tag: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function removeTagFromTenant(tagId: string, tenantId: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/api/v1/admin/tenant-tags/${tagId}/assign/${tenantId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to remove tag assignment: ${res.status}`);
|
||||
}
|
||||
|
|
@ -27,6 +27,11 @@ import {
|
|||
Database,
|
||||
Boxes,
|
||||
Gift,
|
||||
Bell,
|
||||
Tags,
|
||||
Radio,
|
||||
Megaphone,
|
||||
Users2,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ---------- Sidebar context for collapse state ---------- */
|
||||
|
|
@ -112,6 +117,11 @@ export function Sidebar() {
|
|||
{ key: 'users', label: t('users'), href: '/users', icon: <Users className={iconClass} /> },
|
||||
{ key: 'appVersions', label: t('appVersions'), href: '/app-versions', icon: <Smartphone className={iconClass} /> },
|
||||
{ key: 'referral', label: t('referral'), href: '/referral', icon: <Gift className={iconClass} /> },
|
||||
{ key: 'tenantTags', label: t('tenantTags'), href: '/tenant-tags', icon: <Tags className={iconClass} /> },
|
||||
{ key: 'notificationChannels', label: t('notificationChannels'), href: '/notification-channels', icon: <Radio className={iconClass} /> },
|
||||
{ key: 'campaigns', label: t('campaigns'), href: '/campaigns', icon: <Megaphone className={iconClass} /> },
|
||||
{ key: 'segments', label: t('segments'), href: '/segments', icon: <Users2 className={iconClass} /> },
|
||||
{ key: 'notifications', label: t('notifications'), href: '/notifications', icon: <Bell className={iconClass} /> },
|
||||
{ key: 'serverPool', label: '服务器池', href: '/server-pool', icon: <Database className={iconClass} /> },
|
||||
{ key: 'openclawInstances', label: 'OpenClaw 实例', href: '/openclaw-instances', icon: <Boxes className={iconClass} /> },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import '../../features/my_agents/presentation/pages/my_agents_page.dart';
|
|||
import '../../features/billing/presentation/pages/billing_overview_page.dart';
|
||||
import '../../features/profile/presentation/pages/profile_page.dart';
|
||||
import '../../features/notifications/presentation/providers/notification_providers.dart';
|
||||
import '../../features/notifications/presentation/pages/notification_inbox_page.dart';
|
||||
import '../../features/notifications/presentation/pages/notification_preferences_page.dart';
|
||||
import '../../features/referral/presentation/screens/referral_screen.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -56,6 +58,14 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||
path: '/referral',
|
||||
builder: (context, state) => const ReferralScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/notifications/inbox',
|
||||
builder: (context, state) => const NotificationInboxPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/notifications/preferences',
|
||||
builder: (context, state) => const NotificationPreferencesPage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
/// In-site notification fetched from the notification-service backend.
|
||||
class InSiteNotification {
|
||||
final String id;
|
||||
final String title;
|
||||
final String content;
|
||||
final String type; // SYSTEM | MAINTENANCE | FEATURE | ANNOUNCEMENT | BILLING | SECURITY
|
||||
final String priority; // LOW | NORMAL | HIGH | URGENT
|
||||
final String? imageUrl;
|
||||
final String? linkUrl;
|
||||
final bool requiresForceRead;
|
||||
final DateTime? publishedAt;
|
||||
final bool isRead;
|
||||
|
||||
const InSiteNotification({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.type,
|
||||
required this.priority,
|
||||
this.imageUrl,
|
||||
this.linkUrl,
|
||||
required this.requiresForceRead,
|
||||
this.publishedAt,
|
||||
required this.isRead,
|
||||
});
|
||||
|
||||
factory InSiteNotification.fromJson(Map<String, dynamic> json) {
|
||||
return InSiteNotification(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String? ?? '',
|
||||
content: json['content'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'ANNOUNCEMENT',
|
||||
priority: json['priority'] as String? ?? 'NORMAL',
|
||||
imageUrl: json['imageUrl'] as String?,
|
||||
linkUrl: json['linkUrl'] as String?,
|
||||
requiresForceRead: json['requiresForceRead'] as bool? ?? false,
|
||||
publishedAt: json['publishedAt'] != null
|
||||
? DateTime.tryParse(json['publishedAt'] as String)
|
||||
: null,
|
||||
isRead: json['isRead'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
InSiteNotification copyWith({bool? isRead}) => InSiteNotification(
|
||||
id: id,
|
||||
title: title,
|
||||
content: content,
|
||||
type: type,
|
||||
priority: priority,
|
||||
imageUrl: imageUrl,
|
||||
linkUrl: linkUrl,
|
||||
requiresForceRead: requiresForceRead,
|
||||
publishedAt: publishedAt,
|
||||
isRead: isRead ?? this.isRead,
|
||||
);
|
||||
|
||||
String get typeLabel {
|
||||
const labels = {
|
||||
'SYSTEM': '系统通知',
|
||||
'MAINTENANCE': '维护通知',
|
||||
'FEATURE': '新功能',
|
||||
'ANNOUNCEMENT': '公告',
|
||||
'BILLING': '账单通知',
|
||||
'SECURITY': '安全通知',
|
||||
};
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
String get typeEmoji {
|
||||
const emojis = {
|
||||
'SYSTEM': '⚙️',
|
||||
'MAINTENANCE': '🔧',
|
||||
'FEATURE': '✨',
|
||||
'ANNOUNCEMENT': '📢',
|
||||
'BILLING': '💳',
|
||||
'SECURITY': '🔒',
|
||||
};
|
||||
return emojis[type] ?? '📩';
|
||||
}
|
||||
|
||||
String get relativeTime {
|
||||
final ref = publishedAt ?? DateTime.now();
|
||||
final diff = DateTime.now().difference(ref);
|
||||
if (diff.inMinutes < 1) return '刚刚';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
|
||||
if (diff.inDays < 1) return '${diff.inHours}小时前';
|
||||
if (diff.inDays < 30) return '${diff.inDays}天前';
|
||||
return '${ref.year}-${ref.month.toString().padLeft(2, '0')}-${ref.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import 'in_site_notification.dart';
|
||||
|
||||
class InSiteNotificationRepository {
|
||||
final Dio _dio;
|
||||
|
||||
InSiteNotificationRepository(this._dio);
|
||||
|
||||
Future<({List<InSiteNotification> items, int total})> getMyNotifications({
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final res = await _dio.get(
|
||||
'/api/v1/notifications/me',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
final items = (data['items'] as List)
|
||||
.map((e) => InSiteNotification.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return (items: items, total: data['total'] as int? ?? 0);
|
||||
}
|
||||
|
||||
Future<int> getUnreadCount() async {
|
||||
final res = await _dio.get('/api/v1/notifications/me/unread-count');
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
return data['count'] as int? ?? 0;
|
||||
}
|
||||
|
||||
Future<void> markRead(String notificationId) async {
|
||||
await _dio.post(
|
||||
'/api/v1/notifications/me/mark-read',
|
||||
data: {'notificationId': notificationId},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
await _dio.post('/api/v1/notifications/me/mark-read', data: {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Riverpod provider ──────────────────────────────────────────────────────
|
||||
|
||||
final inSiteNotificationRepositoryProvider =
|
||||
Provider<InSiteNotificationRepository>((ref) {
|
||||
final dio = ref.watch(dioClientProvider).dio;
|
||||
return InSiteNotificationRepository(dio);
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
class NotificationChannelPreference {
|
||||
final String channelKey;
|
||||
final String channelName;
|
||||
final bool isMandatory;
|
||||
final bool enabled;
|
||||
|
||||
const NotificationChannelPreference({
|
||||
required this.channelKey,
|
||||
required this.channelName,
|
||||
required this.isMandatory,
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
factory NotificationChannelPreference.fromJson(Map<String, dynamic> json) {
|
||||
return NotificationChannelPreference(
|
||||
channelKey: json['channelKey'] as String? ?? '',
|
||||
channelName: json['channelName'] as String? ?? json['channelKey'] as String? ?? '',
|
||||
isMandatory: json['isMandatory'] as bool? ?? false,
|
||||
enabled: json['enabled'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
NotificationChannelPreference copyWith({bool? enabled}) {
|
||||
return NotificationChannelPreference(
|
||||
channelKey: channelKey,
|
||||
channelName: channelName,
|
||||
isMandatory: isMandatory,
|
||||
enabled: enabled ?? this.enabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/in_site_notification.dart';
|
||||
import '../../data/in_site_notification_repository.dart';
|
||||
import '../providers/notification_providers.dart';
|
||||
import '../widgets/force_read_notification_dialog.dart';
|
||||
|
||||
// ── Color map by notification type ────────────────────────────────────────
|
||||
|
||||
Color _typeColor(String type) {
|
||||
return switch (type) {
|
||||
'SYSTEM' => Colors.grey,
|
||||
'MAINTENANCE' => Colors.orange,
|
||||
'FEATURE' => Colors.green,
|
||||
'BILLING' => Colors.purple,
|
||||
'SECURITY' => Colors.red,
|
||||
_ => Colors.blue, // ANNOUNCEMENT + default
|
||||
};
|
||||
}
|
||||
|
||||
// ── Inbox Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
class NotificationInboxPage extends ConsumerStatefulWidget {
|
||||
const NotificationInboxPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NotificationInboxPage> createState() =>
|
||||
_NotificationInboxPageState();
|
||||
}
|
||||
|
||||
class _NotificationInboxPageState
|
||||
extends ConsumerState<NotificationInboxPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Show force-read dialogs after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkForceRead());
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
ref.invalidate(inSiteNotificationsProvider);
|
||||
ref.invalidate(inSiteUnreadCountProvider);
|
||||
}
|
||||
|
||||
void _checkForceRead() {
|
||||
final asyncVal = ref.read(inSiteNotificationsProvider);
|
||||
asyncVal.whenData((items) {
|
||||
final forceItems =
|
||||
items.where((n) => n.requiresForceRead && !n.isRead).toList();
|
||||
if (forceItems.isEmpty || !mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => ForceReadNotificationDialog(
|
||||
notifications: forceItems,
|
||||
onMarkRead: (id) => ref
|
||||
.read(inSiteNotificationRepositoryProvider)
|
||||
.markRead(id),
|
||||
onAllDone: _refresh,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
try {
|
||||
await ref
|
||||
.read(inSiteNotificationRepositoryProvider)
|
||||
.markAllRead();
|
||||
await _refresh();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('操作失败,请重试')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markRead(InSiteNotification item) async {
|
||||
if (item.isRead) return;
|
||||
try {
|
||||
await ref
|
||||
.read(inSiteNotificationRepositoryProvider)
|
||||
.markRead(item.id);
|
||||
await _refresh();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _showDetail(InSiteNotification item) {
|
||||
_markRead(item);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
maxChildSize: 0.9,
|
||||
builder: (_, controller) => ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[400],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: _typeColor(item.type).withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: _typeColor(item.type).withAlpha(80)),
|
||||
),
|
||||
child: Text(
|
||||
item.typeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _typeColor(item.type),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
item.relativeTime,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
item.content,
|
||||
style: const TextStyle(fontSize: 15, height: 1.6),
|
||||
),
|
||||
if (item.linkUrl != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'链接:${item.linkUrl}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: Colors.blue),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asyncItems = ref.watch(inSiteNotificationsProvider);
|
||||
final hasUnread = asyncItems.whenOrNull(
|
||||
data: (items) => items.any((n) => !n.isRead),
|
||||
) ??
|
||||
false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('站内消息'),
|
||||
actions: [
|
||||
if (hasUnread)
|
||||
TextButton(
|
||||
onPressed: _markAllRead,
|
||||
child: const Text('全部已读'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: asyncItems.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 12),
|
||||
Text('加载失败', style: TextStyle(color: Colors.grey[600])),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _refresh, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.notifications_none,
|
||||
size: 64, color: Colors.grey),
|
||||
SizedBox(height: 12),
|
||||
Text('暂无消息',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: ListView.separated(
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, i) {
|
||||
final item = items[i];
|
||||
final color = _typeColor(item.type);
|
||||
return InkWell(
|
||||
onTap: () => _showDetail(item),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(color: color, width: 3),
|
||||
),
|
||||
color: item.isRead
|
||||
? null
|
||||
: color.withAlpha(10),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Emoji icon
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(item.typeEmoji,
|
||||
style: const TextStyle(fontSize: 18)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Content
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Unread dot
|
||||
if (!item.isRead) ...[
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
margin: const EdgeInsets.only(right: 6, top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.normal
|
||||
: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
item.relativeTime,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Colors.grey),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
item.typeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 10, color: color),
|
||||
),
|
||||
),
|
||||
if (item.requiresForceRead) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withAlpha(20),
|
||||
borderRadius:
|
||||
BorderRadius.circular(3),
|
||||
),
|
||||
child: const Text(
|
||||
'强制阅读',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
size: 16, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../data/notification_channel.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
|
||||
// ── Providers ──────────────────────────────────────────────────────────────
|
||||
|
||||
final _channelPrefsProvider = FutureProvider.autoDispose<List<NotificationChannelPreference>>((ref) async {
|
||||
final dio = ref.watch(dioClientProvider).dio;
|
||||
final res = await dio.get('/api/v1/notifications/channels/me/preferences');
|
||||
final list = res.data as List;
|
||||
return list
|
||||
.map((e) => NotificationChannelPreference.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class NotificationPreferencesPage extends ConsumerStatefulWidget {
|
||||
const NotificationPreferencesPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NotificationPreferencesPage> createState() => _NotificationPreferencesPageState();
|
||||
}
|
||||
|
||||
class _NotificationPreferencesPageState extends ConsumerState<NotificationPreferencesPage> {
|
||||
final Map<String, bool> _pendingChanges = {};
|
||||
bool _saving = false;
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
if (_pendingChanges.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final dio = ref.read(dioClientProvider).dio;
|
||||
final preferences = _pendingChanges.entries
|
||||
.map((e) => {'channelKey': e.key, 'enabled': e.value})
|
||||
.toList();
|
||||
await dio.put(
|
||||
'/api/v1/notifications/channels/me/preferences',
|
||||
data: {'preferences': preferences},
|
||||
);
|
||||
_pendingChanges.clear();
|
||||
ref.invalidate(_channelPrefsProvider);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('通知偏好已保存'), duration: Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('保存失败: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final prefsAsync = ref.watch(_channelPrefsProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('通知偏好设置'),
|
||||
actions: [
|
||||
if (_pendingChanges.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _saving ? null : _saveChanges,
|
||||
child: _saving
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: prefsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('加载失败', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(_channelPrefsProvider),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (prefs) {
|
||||
if (prefs.isEmpty) {
|
||||
return const Center(child: Text('暂无可配置的通知频道'));
|
||||
}
|
||||
|
||||
final mandatory = prefs.where((p) => p.isMandatory).toList();
|
||||
final optional = prefs.where((p) => !p.isMandatory).toList();
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
// Info banner
|
||||
Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'您可以选择接收哪些类型的通知。强制通知(如安全告警)无法关闭。',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Mandatory channels
|
||||
if (mandatory.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text(
|
||||
'重要通知(不可关闭)',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
...mandatory.map((pref) => _ChannelTile(
|
||||
pref: pref,
|
||||
currentValue: _pendingChanges[pref.channelKey] ?? pref.enabled,
|
||||
onChanged: null, // mandatory — disabled
|
||||
)),
|
||||
],
|
||||
|
||||
// Optional channels
|
||||
if (optional.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
'可选通知',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
...optional.map((pref) => _ChannelTile(
|
||||
pref: pref,
|
||||
currentValue: _pendingChanges[pref.channelKey] ?? pref.enabled,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_pendingChanges[pref.channelKey] = val;
|
||||
});
|
||||
},
|
||||
)),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Save button at bottom
|
||||
if (_pendingChanges.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _saveChanges,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('保存偏好设置'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelTile extends StatelessWidget {
|
||||
final NotificationChannelPreference pref;
|
||||
final bool currentValue;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
|
||||
const _ChannelTile({
|
||||
required this.pref,
|
||||
required this.currentValue,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SwitchListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(pref.channelName),
|
||||
if (pref.isMandatory) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'必需',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: Colors.amber.shade800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
pref.channelKey,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
value: currentValue,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../../../../core/config/app_config.dart';
|
||||
import '../../../../core/services/notification_service.dart';
|
||||
import '../../domain/entities/app_notification.dart';
|
||||
import '../../data/in_site_notification.dart';
|
||||
import '../../data/in_site_notification_repository.dart';
|
||||
|
||||
/// Global local notifications plugin instance (initialized in main.dart).
|
||||
final localNotificationsPluginProvider =
|
||||
|
|
@ -20,7 +22,7 @@ final notificationServiceProvider = Provider<NotificationService>((ref) {
|
|||
);
|
||||
});
|
||||
|
||||
/// Accumulated notification list.
|
||||
/// Accumulated push notification list (local/realtime).
|
||||
class NotificationListNotifier extends StateNotifier<List<AppNotification>> {
|
||||
NotificationListNotifier() : super([]);
|
||||
|
||||
|
|
@ -50,8 +52,39 @@ final notificationListProvider =
|
|||
return NotificationListNotifier();
|
||||
});
|
||||
|
||||
/// Unread notification count.
|
||||
/// Local push notification unread count.
|
||||
final unreadNotificationCountProvider = Provider<int>((ref) {
|
||||
final notifications = ref.watch(notificationListProvider);
|
||||
return notifications.where((n) => !n.isRead).length;
|
||||
});
|
||||
|
||||
// ── In-site (站内消息) notification providers ──────────────────────────────
|
||||
|
||||
/// Polls the backend every 30s for the unread in-site notification count.
|
||||
final inSiteUnreadCountProvider = StreamProvider<int>((ref) async* {
|
||||
final repo = ref.watch(inSiteNotificationRepositoryProvider);
|
||||
|
||||
Future<int> fetch() async {
|
||||
try {
|
||||
return await repo.getUnreadCount();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit immediately on first build
|
||||
yield await fetch();
|
||||
|
||||
// Then poll every 30s
|
||||
await for (final _ in Stream.periodic(const Duration(seconds: 30))) {
|
||||
yield await fetch();
|
||||
}
|
||||
});
|
||||
|
||||
/// Fetches the full in-site notification list (on demand, refreshed when invalidated).
|
||||
final inSiteNotificationsProvider =
|
||||
FutureProvider<List<InSiteNotification>>((ref) async {
|
||||
final repo = ref.watch(inSiteNotificationRepositoryProvider);
|
||||
final result = await repo.getMyNotifications(limit: 100);
|
||||
return result.items;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../data/in_site_notification.dart';
|
||||
|
||||
/// Non-dismissible dialog shown for notifications with requiresForceRead=true.
|
||||
/// Displays notifications sequentially; last one requires checkbox acknowledgement.
|
||||
class ForceReadNotificationDialog extends StatefulWidget {
|
||||
final List<InSiteNotification> notifications;
|
||||
final Future<void> Function(String notificationId) onMarkRead;
|
||||
final VoidCallback onAllDone;
|
||||
|
||||
const ForceReadNotificationDialog({
|
||||
super.key,
|
||||
required this.notifications,
|
||||
required this.onMarkRead,
|
||||
required this.onAllDone,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ForceReadNotificationDialog> createState() =>
|
||||
_ForceReadNotificationDialogState();
|
||||
}
|
||||
|
||||
class _ForceReadNotificationDialogState
|
||||
extends State<ForceReadNotificationDialog> {
|
||||
int _current = 0;
|
||||
bool _acknowledged = false;
|
||||
bool _loading = false;
|
||||
|
||||
bool get _isLast => _current == widget.notifications.length - 1;
|
||||
InSiteNotification get _item => widget.notifications[_current];
|
||||
|
||||
Future<void> _next() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onMarkRead(_item.id);
|
||||
} catch (_) {}
|
||||
setState(() {
|
||||
_current++;
|
||||
_acknowledged = false;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _confirm() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await widget.onMarkRead(_item.id);
|
||||
} catch (_) {}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
widget.onAllDone();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.priority_high, color: Colors.red, size: 18),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'重要通知',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.red[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${_current + 1}/${widget.notifications.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_item.title,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Type badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_item.typeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Content
|
||||
Text(_item.content, style: const TextStyle(fontSize: 14, height: 1.5)),
|
||||
// Acknowledgement checkbox on last notification
|
||||
if (_isLast) ...[
|
||||
const SizedBox(height: 20),
|
||||
const Divider(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _acknowledged = !_acknowledged),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _acknowledged,
|
||||
onChanged: (v) =>
|
||||
setState(() => _acknowledged = v ?? false),
|
||||
),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'我已阅读并确认以上内容',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (!_isLast)
|
||||
TextButton(
|
||||
onPressed: _loading ? null : _next,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('下一条'),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: (_acknowledged && !_loading) ? _confirm : null,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('确认已读'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import '../../../../core/updater/update_service.dart';
|
|||
import '../../../auth/data/providers/auth_provider.dart';
|
||||
import '../../../agent_call/presentation/pages/voice_test_page.dart';
|
||||
import '../../../settings/presentation/providers/settings_providers.dart';
|
||||
import '../../../notifications/presentation/providers/notification_providers.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile page — "我" Tab
|
||||
|
|
@ -85,6 +86,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||
),
|
||||
onTap: () => context.push('/referral'),
|
||||
),
|
||||
_InSiteMessageRow(subtitleColor: subtitleColor),
|
||||
_SettingsRow(
|
||||
icon: Icons.tune_outlined,
|
||||
label: '通知偏好设置',
|
||||
subtitle: '管理各类通知的订阅开关',
|
||||
subtitleColor: subtitleColor,
|
||||
onTap: () => context.push('/notifications/preferences'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
|
@ -956,6 +965,59 @@ class _ThemeLabel extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// In-site message row with unread badge.
|
||||
class _InSiteMessageRow extends ConsumerWidget {
|
||||
final Color? subtitleColor;
|
||||
const _InSiteMessageRow({this.subtitleColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unreadAsync = ref.watch(inSiteUnreadCountProvider);
|
||||
final unread = unreadAsync.valueOrNull ?? 0;
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEF4444),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.notifications_outlined,
|
||||
color: Colors.white, size: 18),
|
||||
),
|
||||
title: const Text('站内消息'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (unread > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEF4444),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
unread > 99 ? '99+' : '$unread',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text('查看消息',
|
||||
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.chevron_right,
|
||||
size: 20, color: Theme.of(context).hintColor),
|
||||
],
|
||||
),
|
||||
onTap: () => context.push('/notifications/inbox'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeOption extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
|
|
|||
|
|
@ -172,6 +172,35 @@ services:
|
|||
- /api/v1/referral/validate
|
||||
strip_path: false
|
||||
|
||||
- name: notification-service
|
||||
url: http://notification-service:3013
|
||||
routes:
|
||||
# User-facing: GET /api/v1/notifications/me, /me/unread-count; POST /me/mark-read
|
||||
- name: notification-user-routes
|
||||
paths:
|
||||
- /api/v1/notifications/me
|
||||
strip_path: false
|
||||
# Admin: /api/v1/notifications/admin (JWT + platform_admin role enforced in service)
|
||||
- name: notification-admin-routes
|
||||
paths:
|
||||
- /api/v1/notifications/admin
|
||||
strip_path: false
|
||||
# Channels: admin CRUD + user preferences (JWT enforced)
|
||||
- name: notification-channel-routes
|
||||
paths:
|
||||
- /api/v1/notifications/channels
|
||||
strip_path: false
|
||||
# Campaigns: admin CRUD + scheduling + analytics
|
||||
- name: notification-campaign-routes
|
||||
paths:
|
||||
- /api/v1/notifications/campaigns
|
||||
strip_path: false
|
||||
# Segments: admin audience group management
|
||||
- name: notification-segment-routes
|
||||
paths:
|
||||
- /api/v1/notifications/segments
|
||||
strip_path: false
|
||||
|
||||
plugins:
|
||||
# ===== Global plugins (apply to ALL routes) =====
|
||||
- name: cors
|
||||
|
|
@ -306,6 +335,42 @@ plugins:
|
|||
claims_to_verify:
|
||||
- exp
|
||||
|
||||
# JWT for notification-service (role enforcement done in service)
|
||||
- name: jwt
|
||||
route: notification-user-routes
|
||||
config:
|
||||
key_claim_name: kid
|
||||
claims_to_verify:
|
||||
- exp
|
||||
|
||||
- name: jwt
|
||||
route: notification-admin-routes
|
||||
config:
|
||||
key_claim_name: kid
|
||||
claims_to_verify:
|
||||
- exp
|
||||
|
||||
- name: jwt
|
||||
route: notification-channel-routes
|
||||
config:
|
||||
key_claim_name: kid
|
||||
claims_to_verify:
|
||||
- exp
|
||||
|
||||
- name: jwt
|
||||
route: notification-campaign-routes
|
||||
config:
|
||||
key_claim_name: kid
|
||||
claims_to_verify:
|
||||
- exp
|
||||
|
||||
- name: jwt
|
||||
route: notification-segment-routes
|
||||
config:
|
||||
key_claim_name: kid
|
||||
claims_to_verify:
|
||||
- exp
|
||||
|
||||
# ===== Route-specific overrides =====
|
||||
- name: rate-limiting
|
||||
route: agent-ws
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { SettingsController } from './interfaces/rest/controllers/settings.contr
|
|||
import { RoleController } from './interfaces/rest/controllers/role.controller';
|
||||
import { PermissionController } from './interfaces/rest/controllers/permission.controller';
|
||||
import { SmsController } from './interfaces/rest/controllers/sms.controller';
|
||||
import { TenantTagController } from './interfaces/rest/controllers/tenant-tag.controller';
|
||||
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
|
||||
import { RbacGuard } from './infrastructure/guards/rbac.guard';
|
||||
import { AuthService } from './application/services/auth.service';
|
||||
|
|
@ -23,12 +24,14 @@ import { Role } from './domain/entities/role.entity';
|
|||
import { ApiKey } from './domain/entities/api-key.entity';
|
||||
import { Tenant } from './domain/entities/tenant.entity';
|
||||
import { TenantInvite } from './domain/entities/tenant-invite.entity';
|
||||
import { TenantTag } from './domain/entities/tenant-tag.entity';
|
||||
import { TenantTagAssignment } from './domain/entities/tenant-tag-assignment.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
DatabaseModule.forRoot(),
|
||||
TypeOrmModule.forFeature([User, Role, ApiKey, Tenant, TenantInvite]),
|
||||
TypeOrmModule.forFeature([User, Role, ApiKey, Tenant, TenantInvite, TenantTag, TenantTagAssignment]),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
useFactory: () => ({
|
||||
|
|
@ -37,7 +40,7 @@ import { TenantInvite } from './domain/entities/tenant-invite.entity';
|
|||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController, TenantController, UserController, SettingsController, RoleController, PermissionController, SmsController],
|
||||
controllers: [AuthController, TenantController, UserController, SettingsController, RoleController, PermissionController, SmsController, TenantTagController],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
RbacGuard,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { Entity, Column, PrimaryColumn, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'tenant_tag_assignments', schema: 'public' })
|
||||
export class TenantTagAssignment {
|
||||
@PrimaryColumn({ name: 'tenant_id', type: 'varchar', length: 100 })
|
||||
tenantId!: string;
|
||||
|
||||
@PrimaryColumn({ name: 'tag_id', type: 'uuid' })
|
||||
tagId!: string;
|
||||
|
||||
@Column({ name: 'tagged_by', type: 'varchar', length: 100, nullable: true })
|
||||
taggedBy!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'tagged_at', type: 'timestamptz' })
|
||||
taggedAt!: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'tenant_tags', schema: 'public' })
|
||||
export class TenantTag {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, unique: true })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: '#6366F1' })
|
||||
color!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ name: 'created_by', type: 'varchar', length: 100, nullable: true })
|
||||
createdBy!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { RolesGuard, Roles } from '@it0/common';
|
||||
import { TenantTag } from '../../../domain/entities/tenant-tag.entity';
|
||||
import { TenantTagAssignment } from '../../../domain/entities/tenant-tag-assignment.entity';
|
||||
|
||||
/**
|
||||
* Platform admin API for managing tenant tags.
|
||||
* Tags can be created, applied to tenants, and used as notification targeting criteria.
|
||||
*/
|
||||
@Controller('api/v1/admin/tenant-tags')
|
||||
@UseGuards(RolesGuard)
|
||||
export class TenantTagController {
|
||||
constructor(
|
||||
@InjectRepository(TenantTag)
|
||||
private readonly tagRepo: Repository<TenantTag>,
|
||||
@InjectRepository(TenantTagAssignment)
|
||||
private readonly assignRepo: Repository<TenantTagAssignment>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
// ── Tag CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/v1/admin/tenant-tags — list all tags with tenant counts */
|
||||
@Get()
|
||||
@Roles('platform_admin', 'platform_super_admin')
|
||||
async listTags() {
|
||||
const tags = await this.tagRepo.find({ order: { createdAt: 'DESC' } });
|
||||
// Get tenant counts per tag
|
||||
const counts = await this.dataSource.query(
|
||||
`SELECT tag_id, COUNT(*)::int AS cnt
|
||||
FROM public.tenant_tag_assignments
|
||||
GROUP BY tag_id`,
|
||||
);
|
||||
const countMap = Object.fromEntries(counts.map((r: any) => [r.tag_id, r.cnt]));
|
||||
return tags.map((t) => ({ ...t, tenantCount: countMap[t.id] ?? 0 }));
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/tenant-tags — create a tag */
|
||||
@Post()
|
||||
@Roles('platform_admin', 'platform_super_admin')
|
||||
async createTag(@Body() body: { name: string; color?: string; description?: string }, @Request() req: any) {
|
||||
const existing = await this.tagRepo.findOne({ where: { name: body.name } });
|
||||
if (existing) throw new ConflictException(`Tag "${body.name}" already exists`);
|
||||
|
||||
const tag = this.tagRepo.create({
|
||||
name: body.name.trim(),
|
||||
color: body.color ?? '#6366F1',
|
||||
description: body.description ?? null,
|
||||
createdBy: req.user?.id ?? null,
|
||||
});
|
||||
return this.tagRepo.save(tag);
|
||||
}
|
||||
|
||||
/** PUT /api/v1/admin/tenant-tags/:id — update a tag */
|
||||
@Put(':id')
|
||||
@Roles('platform_admin', 'platform_super_admin')
|
||||
async updateTag(@Param('id') id: string, @Body() body: { name?: string; color?: string; description?: string }) {
|
||||
const tag = await this.tagRepo.findOne({ where: { id } });
|
||||
if (!tag) throw new NotFoundException('Tag not found');
|
||||
if (body.name !== undefined) tag.name = body.name.trim();
|
||||
if (body.color !== undefined) tag.color = body.color;
|
||||
if (body.description !== undefined) tag.description = body.description;
|
||||
return this.tagRepo.save(tag);
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/admin/tenant-tags/:id — delete a tag (removes all assignments) */
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Roles('platform_admin', 'platform_super_admin')
|
||||
async deleteTag(@Param('id') id: string) {
|
||||
const tag = await this.tagRepo.findOne({ where: { id } });
|
||||
if (!tag) throw new NotFoundException('Tag not found');
|
||||
await this.tagRepo.remove(tag);
|
||||
}
|
||||
|
||||
// ── Tag Assignments ───────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/v1/admin/tenant-tags/tenants/:tenantId — get tags of a tenant */
|
||||
@Get('tenants/:tenantId')
|
||||
@Roles('platform_admin', 'platform_super_admin')
|
||||
async getTagsForTenant(@Param('tenantId') tenantId: string) {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT t.*, a.tagged_by, a.tagged_at
|
||||
FROM public.tenant_tags t
|
||||
JOIN public.tenant_tag_assignments a ON a.tag_id = t.id
|
||||
WHERE a.tenant_id = $1
|
||||
ORDER BY a.tagged_at DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/tenant-tags/:id/tenants — get tenants with a given tag */
|
||||
@Get(':id/tenants')
|
||||
@Roles('platform_admin', 'platform_super_admin')
|
||||
async getTenantsForTag(
|
||||
@Param('id') tagId: string,
|
||||
@Query('limit') limit = '100',
|
||||
@Query('offset') offset = '0',
|
||||
) {
|
||||
const tag = await this.tagRepo.findOne({ where: { id: tagId } });
|
||||
if (!tag) throw new NotFoundException('Tag not found');
|
||||
|
||||
const [rows, count] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT t.id, t.name, t.slug, t.plan, t.status, a.tagged_at
|
||||
FROM public.tenants t
|
||||
JOIN public.tenant_tag_assignments a ON a.tenant_id = t.id
|
||||
WHERE a.tag_id = $1
|
||||
ORDER BY a.tagged_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[tagId, parseInt(limit), parseInt(offset)],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT COUNT(*)::int AS total FROM public.tenant_tag_assignments WHERE tag_id = $1`,
|
||||
[tagId],
|
||||
),
|
||||
]);
|
||||
return { items: rows, total: count[0]?.total ?? 0 };
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/tenant-tags/:id/assign — assign tag to tenants */
|
||||
@Post(':id/assign')
|
||||
@Roles('platform_admin', 'platform_super_admin')
|
||||
async assignTag(
|
||||
@Param('id') tagId: string,
|
||||
@Body() body: { tenantIds: string[] },
|
||||
@Request() req: any,
|
||||
) {
|
||||
const tag = await this.tagRepo.findOne({ where: { id: tagId } });
|
||||
if (!tag) throw new NotFoundException('Tag not found');
|
||||
|
||||
for (const tenantId of body.tenantIds ?? []) {
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO public.tenant_tag_assignments (tenant_id, tag_id, tagged_by)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
[tenantId, tagId, req.user?.id ?? null],
|
||||
);
|
||||
}
|
||||
return { ok: true, assigned: body.tenantIds?.length ?? 0 };
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/admin/tenant-tags/:id/assign/:tenantId — remove tag from tenant */
|
||||
@Delete(':id/assign/:tenantId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Roles('platform_admin', 'platform_super_admin')
|
||||
async removeAssignment(@Param('id') tagId: string, @Param('tenantId') tenantId: string) {
|
||||
await this.dataSource.query(
|
||||
`DELETE FROM public.tenant_tag_assignments WHERE tag_id = $1 AND tenant_id = $2`,
|
||||
[tagId, tenantId],
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/tenant-tags/tenants/:tenantId/tags — set all tags for a tenant */
|
||||
@Post('tenants/:tenantId/tags')
|
||||
@Roles('platform_admin', 'platform_super_admin')
|
||||
async setTenantTags(
|
||||
@Param('tenantId') tenantId: string,
|
||||
@Body() body: { tagIds: string[] },
|
||||
@Request() req: any,
|
||||
) {
|
||||
// Remove existing assignments
|
||||
await this.dataSource.query(
|
||||
`DELETE FROM public.tenant_tag_assignments WHERE tenant_id = $1`,
|
||||
[tenantId],
|
||||
);
|
||||
// Insert new assignments
|
||||
for (const tagId of body.tagIds ?? []) {
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO public.tenant_tag_assignments (tenant_id, tag_id, tagged_by)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
[tenantId, tagId, req.user?.id ?? null],
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@it0/notification-service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"typeorm": "^0.3.20",
|
||||
"pg": "^8.11.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"@it0/common": "workspace:*",
|
||||
"@it0/database": "workspace:*",
|
||||
"ioredis": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { Injectable, OnApplicationBootstrap, OnApplicationShutdown, Logger } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { NotificationRepository } from '../../infrastructure/repositories/notification.repository';
|
||||
|
||||
/**
|
||||
* EventTriggerService — Redis Stream consumer for auto-triggered notifications.
|
||||
*
|
||||
* Listens to platform event streams and auto-creates/sends relevant notifications.
|
||||
*
|
||||
* Supported events:
|
||||
* - events:billing.payment_failed → BILLING notification to specific tenant
|
||||
* - events:billing.quota_warning → BILLING notification to specific tenant
|
||||
* - events:tenant.registered → SYSTEM welcome notification to new tenant
|
||||
* - events:alert.fired → SYSTEM ops alert notification to specific tenant
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventTriggerService implements OnApplicationBootstrap, OnApplicationShutdown {
|
||||
private readonly logger = new Logger(EventTriggerService.name);
|
||||
private redis: Redis;
|
||||
private running = false;
|
||||
|
||||
private readonly GROUP = 'notification-service';
|
||||
private readonly CONSUMER = `notification-service-${process.pid}`;
|
||||
|
||||
private readonly STREAMS = [
|
||||
'events:billing.payment_failed',
|
||||
'events:billing.quota_warning',
|
||||
'events:tenant.registered',
|
||||
'events:alert.fired',
|
||||
];
|
||||
|
||||
constructor(private readonly notificationRepo: NotificationRepository) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
const redisUrl = process.env.REDIS_URL || 'redis://redis:6379';
|
||||
this.redis = new Redis(redisUrl, { lazyConnect: true, enableOfflineQueue: false });
|
||||
this.redis.on('error', (err) => this.logger.warn(`Redis error: ${err.message}`));
|
||||
this.running = true;
|
||||
this.startConsuming();
|
||||
}
|
||||
|
||||
onApplicationShutdown() {
|
||||
this.running = false;
|
||||
this.redis?.quit();
|
||||
}
|
||||
|
||||
private async startConsuming() {
|
||||
// Ensure consumer groups exist
|
||||
for (const stream of this.STREAMS) {
|
||||
try {
|
||||
await this.redis.xgroup('CREATE', stream, this.GROUP, '$', 'MKSTREAM');
|
||||
} catch {
|
||||
// Group already exists — ok
|
||||
}
|
||||
}
|
||||
|
||||
while (this.running) {
|
||||
try {
|
||||
const results = await this.redis.xreadgroup(
|
||||
'GROUP', this.GROUP, this.CONSUMER,
|
||||
'COUNT', '10',
|
||||
'BLOCK', '5000',
|
||||
'STREAMS', ...this.STREAMS, ...this.STREAMS.map(() => '>'),
|
||||
) as [string, [string, string[]][]][] | null;
|
||||
|
||||
if (!results) continue;
|
||||
|
||||
for (const [stream, messages] of results) {
|
||||
for (const [id, fields] of messages) {
|
||||
try {
|
||||
const data = JSON.parse(fields[1]);
|
||||
await this.handleEvent(stream, data);
|
||||
await this.redis.xack(stream, this.GROUP, id);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to process event ${id} on ${stream}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (this.running) {
|
||||
this.logger.warn(`Redis stream error: ${err.message}`);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEvent(stream: string, event: any): Promise<void> {
|
||||
this.logger.debug(`Processing event from ${stream}: tenantId=${event.tenantId}`);
|
||||
|
||||
switch (stream) {
|
||||
case 'events:billing.payment_failed':
|
||||
await this.notificationRepo.create({
|
||||
title: '支付失败提醒',
|
||||
content: `您的账单支付失败,金额 ${event.payload?.amountFormatted ?? ''}。请及时处理,避免服务中断。`,
|
||||
type: 'BILLING',
|
||||
priority: 'HIGH',
|
||||
targetType: 'SPECIFIC_TENANTS',
|
||||
tenantIds: [event.tenantId],
|
||||
requiresForceRead: false,
|
||||
isEnabled: true,
|
||||
channelKey: 'billing',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'events:billing.quota_warning':
|
||||
await this.notificationRepo.create({
|
||||
title: '用量配额预警',
|
||||
content: `您的账户 Token 用量已达到 ${event.payload?.usagePercent ?? 80}%,请关注用量或升级套餐。`,
|
||||
type: 'BILLING',
|
||||
priority: 'NORMAL',
|
||||
targetType: 'SPECIFIC_TENANTS',
|
||||
tenantIds: [event.tenantId],
|
||||
requiresForceRead: false,
|
||||
isEnabled: true,
|
||||
channelKey: 'billing',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'events:tenant.registered': {
|
||||
// Welcome notification to new tenant — target ALL initially,
|
||||
// but for welcome we use SPECIFIC_TENANTS
|
||||
await this.notificationRepo.create({
|
||||
title: '欢迎使用 iAgent!',
|
||||
content: '感谢您注册 iAgent 平台!您现在可以部署 AI 智能体来管理您的服务器集群。如需帮助,请查看文档或联系支持团队。',
|
||||
type: 'ANNOUNCEMENT',
|
||||
priority: 'NORMAL',
|
||||
targetType: 'SPECIFIC_TENANTS',
|
||||
tenantIds: [event.tenantId],
|
||||
requiresForceRead: false,
|
||||
isEnabled: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'events:alert.fired': {
|
||||
const severity = event.payload?.severity ?? 'warning';
|
||||
const priority = severity === 'critical' || severity === 'fatal' ? 'URGENT' : 'HIGH';
|
||||
await this.notificationRepo.create({
|
||||
title: `运维告警:${severity.toUpperCase()}`,
|
||||
content: event.payload?.message ?? '服务器异常告警,请及时处理。',
|
||||
type: 'SYSTEM',
|
||||
priority,
|
||||
targetType: 'SPECIFIC_TENANTS',
|
||||
tenantIds: [event.tenantId],
|
||||
requiresForceRead: priority === 'URGENT',
|
||||
isEnabled: true,
|
||||
channelKey: 'ops',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unhandled event stream: ${stream}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Tracks which users have read which notifications.
|
||||
* userId is from the JWT (public.users.id).
|
||||
*/
|
||||
@Entity({ name: 'notification_reads', schema: 'public' })
|
||||
@Unique(['notificationId', 'userId'])
|
||||
export class NotificationRead {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'notification_id', type: 'uuid' })
|
||||
@Index()
|
||||
notificationId!: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'varchar', length: 100 })
|
||||
@Index()
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'varchar', length: 100 })
|
||||
tenantId!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'read_at', type: 'timestamptz' })
|
||||
readAt!: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* For SPECIFIC_TENANTS target type — lists which tenants should receive the notification.
|
||||
*/
|
||||
@Entity({ name: 'notification_tenant_targets', schema: 'public' })
|
||||
export class NotificationTenantTarget {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'notification_id', type: 'uuid' })
|
||||
@Index()
|
||||
notificationId!: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'varchar', length: 100 })
|
||||
tenantId!: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export type NotificationType =
|
||||
| 'SYSTEM'
|
||||
| 'MAINTENANCE'
|
||||
| 'FEATURE'
|
||||
| 'ANNOUNCEMENT'
|
||||
| 'BILLING'
|
||||
| 'SECURITY';
|
||||
|
||||
export type NotificationPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
||||
|
||||
export type TargetType =
|
||||
| 'ALL' // 全量广播
|
||||
| 'SPECIFIC_TENANTS' // 定点:指定租户 ID 列表
|
||||
| 'SPECIFIC_USERS' // 定点:指定用户 ID 列表(跨租户)
|
||||
| 'BY_TENANT_TAG' // 标签推送:有 X 标签的租户
|
||||
| 'BY_PLAN' // 分组推送:按订阅套餐
|
||||
| 'BY_TENANT_STATUS' // 按租户状态
|
||||
| 'BY_SEGMENT'; // 预定义人群包
|
||||
|
||||
export type TagLogic = 'ANY' | 'ALL';
|
||||
|
||||
@Entity({ name: 'notifications', schema: 'public' })
|
||||
export class Notification {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, default: 'ANNOUNCEMENT' })
|
||||
@Index()
|
||||
type!: NotificationType;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'NORMAL' })
|
||||
priority!: NotificationPriority;
|
||||
|
||||
@Column({ name: 'target_type', type: 'varchar', length: 30, default: 'ALL' })
|
||||
targetType!: TargetType;
|
||||
|
||||
// ── Targeting fields ──────────────────────────────────────────────────────
|
||||
|
||||
/** For BY_TENANT_TAG: array of tag UUIDs */
|
||||
@Column({ name: 'target_tag_ids', type: 'uuid', array: true, nullable: true })
|
||||
targetTagIds!: string[] | null;
|
||||
|
||||
/** For BY_TENANT_TAG: ANY (at least one tag) or ALL (every tag must match) */
|
||||
@Column({ name: 'target_tag_logic', type: 'varchar', length: 3, default: 'ANY' })
|
||||
targetTagLogic!: TagLogic;
|
||||
|
||||
/** For BY_PLAN: e.g. ['free', 'pro'] */
|
||||
@Column({ name: 'target_plans', type: 'text', array: true, nullable: true })
|
||||
targetPlans!: string[] | null;
|
||||
|
||||
/** For BY_TENANT_STATUS: e.g. ['active'] */
|
||||
@Column({ name: 'target_statuses', type: 'text', array: true, nullable: true })
|
||||
targetStatuses!: string[] | null;
|
||||
|
||||
/** For BY_SEGMENT: segment key e.g. 'trial_ending_soon' */
|
||||
@Column({ name: 'target_segment', type: 'varchar', length: 100, nullable: true })
|
||||
targetSegment!: string | null;
|
||||
|
||||
/** Notification channel key (Phase 2) — used for user opt-out filtering */
|
||||
@Column({ name: 'channel_key', type: 'varchar', length: 50, nullable: true })
|
||||
channelKey!: string | null;
|
||||
|
||||
// ── Display fields ────────────────────────────────────────────────────────
|
||||
|
||||
@Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true })
|
||||
imageUrl!: string | null;
|
||||
|
||||
@Column({ name: 'link_url', type: 'varchar', length: 500, nullable: true })
|
||||
linkUrl!: string | null;
|
||||
|
||||
@Column({ name: 'requires_force_read', type: 'boolean', default: false })
|
||||
requiresForceRead!: boolean;
|
||||
|
||||
@Column({ name: 'is_enabled', type: 'boolean', default: true })
|
||||
isEnabled!: boolean;
|
||||
|
||||
@Column({ name: 'published_at', type: 'timestamptz', nullable: true })
|
||||
publishedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
|
||||
@Column({ name: 'created_by', type: 'varchar', length: 100, nullable: true })
|
||||
createdBy!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export type CampaignStatus = 'DRAFT' | 'SCHEDULED' | 'RUNNING' | 'COMPLETED' | 'CANCELLED' | 'FAILED';
|
||||
export type ScheduleType = 'ONCE' | 'RECURRING';
|
||||
|
||||
export interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: CampaignStatus;
|
||||
notificationId: string | null;
|
||||
scheduleType: ScheduleType;
|
||||
scheduledAt: Date | null;
|
||||
cronExpr: string | null;
|
||||
timezone: string;
|
||||
nextRunAt: Date | null;
|
||||
lastRunAt: Date | null;
|
||||
targetCount: number;
|
||||
sentCount: number;
|
||||
readCount: number;
|
||||
createdBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateCampaignDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
notificationId?: string;
|
||||
scheduleType: ScheduleType;
|
||||
scheduledAt?: Date | null;
|
||||
cronExpr?: string | null;
|
||||
timezone?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CampaignRepository {
|
||||
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||
|
||||
async create(dto: CreateCampaignDto): Promise<Campaign> {
|
||||
const result = await this.ds.query(
|
||||
`INSERT INTO public.notification_campaigns
|
||||
(name, description, notification_id, schedule_type, scheduled_at, cron_expr, timezone, next_run_at, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING *`,
|
||||
[
|
||||
dto.name, dto.description ?? null, dto.notificationId ?? null,
|
||||
dto.scheduleType, dto.scheduledAt ?? null, dto.cronExpr ?? null,
|
||||
dto.timezone ?? 'UTC',
|
||||
dto.scheduleType === 'ONCE' ? dto.scheduledAt ?? null : null,
|
||||
dto.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
return this.mapRow(result[0]);
|
||||
}
|
||||
|
||||
async findAll(params: { status?: string; limit: number; offset: number }): Promise<{ items: Campaign[]; total: number }> {
|
||||
const conds: string[] = [];
|
||||
const args: any[] = [];
|
||||
let p = 1;
|
||||
if (params.status) { conds.push(`status = $${p++}`); args.push(params.status); }
|
||||
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const [rows, count] = await Promise.all([
|
||||
this.ds.query(
|
||||
`SELECT * FROM public.notification_campaigns ${where} ORDER BY created_at DESC LIMIT $${p} OFFSET $${p + 1}`,
|
||||
[...args, params.limit, params.offset],
|
||||
),
|
||||
this.ds.query(`SELECT COUNT(*)::int AS total FROM public.notification_campaigns ${where}`, args),
|
||||
]);
|
||||
return { items: rows.map(this.mapRow), total: count[0]?.total ?? 0 };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Campaign | null> {
|
||||
const rows = await this.ds.query(`SELECT * FROM public.notification_campaigns WHERE id = $1`, [id]);
|
||||
return rows.length ? this.mapRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async update(id: string, dto: Partial<CreateCampaignDto> & { status?: CampaignStatus; nextRunAt?: Date | null; lastRunAt?: Date | null; sentCount?: number; readCount?: number }): Promise<Campaign | null> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
const add = (col: string, val: any) => { fields.push(`${col} = $${idx++}`); values.push(val); };
|
||||
if (dto.name !== undefined) add('name', dto.name);
|
||||
if (dto.description !== undefined) add('description', dto.description);
|
||||
if (dto.notificationId !== undefined) add('notification_id', dto.notificationId);
|
||||
if (dto.scheduleType !== undefined) add('schedule_type', dto.scheduleType);
|
||||
if (dto.scheduledAt !== undefined) add('scheduled_at', dto.scheduledAt);
|
||||
if (dto.cronExpr !== undefined) add('cron_expr', dto.cronExpr);
|
||||
if (dto.timezone !== undefined) add('timezone', dto.timezone);
|
||||
if (dto.status !== undefined) add('status', dto.status);
|
||||
if (dto.nextRunAt !== undefined) add('next_run_at', dto.nextRunAt);
|
||||
if (dto.lastRunAt !== undefined) add('last_run_at', dto.lastRunAt);
|
||||
if (dto.sentCount !== undefined) add('sent_count', dto.sentCount);
|
||||
if (dto.readCount !== undefined) add('read_count', dto.readCount);
|
||||
fields.push('updated_at = NOW()');
|
||||
values.push(id);
|
||||
const result = await this.ds.query(
|
||||
`UPDATE public.notification_campaigns SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values,
|
||||
);
|
||||
return result.length ? this.mapRow(result[0]) : null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.ds.query(`DELETE FROM public.notification_campaigns WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
async getStats(campaignId: string): Promise<{
|
||||
targetCount: number;
|
||||
sentCount: number;
|
||||
readCount: number;
|
||||
readRate: number;
|
||||
executions: any[];
|
||||
}> {
|
||||
const [campaign, execs] = await Promise.all([
|
||||
this.findById(campaignId),
|
||||
this.ds.query(
|
||||
`SELECT * FROM public.campaign_execution_log WHERE campaign_id = $1 ORDER BY started_at DESC LIMIT 20`,
|
||||
[campaignId],
|
||||
),
|
||||
]);
|
||||
const target = campaign?.targetCount ?? 0;
|
||||
const sent = campaign?.sentCount ?? 0;
|
||||
const read = campaign?.readCount ?? 0;
|
||||
return {
|
||||
targetCount: target,
|
||||
sentCount: sent,
|
||||
readCount: read,
|
||||
readRate: sent > 0 ? Math.round((read / sent) * 1000) / 10 : 0,
|
||||
executions: execs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Find campaigns due for execution (ONCE or RECURRING with next_run_at <= now) */
|
||||
async findDue(): Promise<Campaign[]> {
|
||||
const rows = await this.ds.query(
|
||||
`SELECT * FROM public.notification_campaigns
|
||||
WHERE status = 'SCHEDULED' AND next_run_at IS NOT NULL AND next_run_at <= NOW()
|
||||
ORDER BY next_run_at ASC
|
||||
LIMIT 50`,
|
||||
);
|
||||
return rows.map(this.mapRow);
|
||||
}
|
||||
|
||||
async logExecution(campaignId: string, notificationId: string | null, targetCount: number): Promise<string> {
|
||||
const result = await this.ds.query(
|
||||
`INSERT INTO public.campaign_execution_log (campaign_id, notification_id, target_count)
|
||||
VALUES ($1, $2, $3) RETURNING id`,
|
||||
[campaignId, notificationId, targetCount],
|
||||
);
|
||||
return result[0].id;
|
||||
}
|
||||
|
||||
async finishExecution(execId: string, sentCount: number, status: 'COMPLETED' | 'FAILED', error?: string): Promise<void> {
|
||||
await this.ds.query(
|
||||
`UPDATE public.campaign_execution_log
|
||||
SET finished_at = NOW(), status = $2, sent_count = $3, error = $4
|
||||
WHERE id = $1`,
|
||||
[execId, status, sentCount, error ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
private mapRow(r: any): Campaign {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
status: r.status,
|
||||
notificationId: r.notification_id,
|
||||
scheduleType: r.schedule_type,
|
||||
scheduledAt: r.scheduled_at,
|
||||
cronExpr: r.cron_expr,
|
||||
timezone: r.timezone,
|
||||
nextRunAt: r.next_run_at,
|
||||
lastRunAt: r.last_run_at,
|
||||
targetCount: r.target_count,
|
||||
sentCount: r.sent_count,
|
||||
readCount: r.read_count,
|
||||
createdBy: r.created_by,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export interface NotificationChannel {
|
||||
channelKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isMandatory: boolean;
|
||||
isEnabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserChannelPreference {
|
||||
userId: string;
|
||||
channelKey: string;
|
||||
enabled: boolean;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChannelRepository {
|
||||
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||
|
||||
async listChannels(): Promise<NotificationChannel[]> {
|
||||
const rows = await this.ds.query(
|
||||
`SELECT * FROM public.notification_channels WHERE is_enabled = true ORDER BY is_mandatory DESC, channel_key`,
|
||||
);
|
||||
return rows.map(this.mapChannel);
|
||||
}
|
||||
|
||||
async createChannel(dto: {
|
||||
channelKey: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isMandatory?: boolean;
|
||||
}): Promise<NotificationChannel> {
|
||||
const result = await this.ds.query(
|
||||
`INSERT INTO public.notification_channels (channel_key, name, description, is_mandatory)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[dto.channelKey, dto.name, dto.description ?? null, dto.isMandatory ?? false],
|
||||
);
|
||||
return this.mapChannel(result[0]);
|
||||
}
|
||||
|
||||
async updateChannel(
|
||||
channelKey: string,
|
||||
dto: { name?: string; description?: string; isMandatory?: boolean; isEnabled?: boolean },
|
||||
): Promise<NotificationChannel | null> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
if (dto.name !== undefined) { fields.push(`name = $${idx++}`); values.push(dto.name); }
|
||||
if (dto.description !== undefined) { fields.push(`description = $${idx++}`); values.push(dto.description); }
|
||||
if (dto.isMandatory !== undefined) { fields.push(`is_mandatory = $${idx++}`); values.push(dto.isMandatory); }
|
||||
if (dto.isEnabled !== undefined) { fields.push(`is_enabled = $${idx++}`); values.push(dto.isEnabled); }
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(channelKey);
|
||||
const result = await this.ds.query(
|
||||
`UPDATE public.notification_channels SET ${fields.join(', ')} WHERE channel_key = $${idx} RETURNING *`,
|
||||
values,
|
||||
);
|
||||
return result.length ? this.mapChannel(result[0]) : null;
|
||||
}
|
||||
|
||||
async deleteChannel(channelKey: string): Promise<void> {
|
||||
await this.ds.query(`DELETE FROM public.notification_channels WHERE channel_key = $1`, [channelKey]);
|
||||
}
|
||||
|
||||
// ── User preferences ──────────────────────────────────────────────────────
|
||||
|
||||
async getUserPreferences(userId: string): Promise<UserChannelPreference[]> {
|
||||
const rows = await this.ds.query(
|
||||
`SELECT p.*, c.name, c.is_mandatory
|
||||
FROM public.notification_channels c
|
||||
LEFT JOIN public.user_notification_preferences p ON p.user_id = $1 AND p.channel_key = c.channel_key
|
||||
WHERE c.is_enabled = true
|
||||
ORDER BY c.is_mandatory DESC, c.channel_key`,
|
||||
[userId],
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
userId,
|
||||
channelKey: r.channel_key,
|
||||
channelName: r.name,
|
||||
isMandatory: r.is_mandatory,
|
||||
enabled: r.enabled !== null ? r.enabled : true,
|
||||
updatedAt: r.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
async setUserPreference(userId: string, channelKey: string, enabled: boolean): Promise<void> {
|
||||
// Cannot disable mandatory channels
|
||||
const channel = await this.ds.query(
|
||||
`SELECT is_mandatory FROM public.notification_channels WHERE channel_key = $1`,
|
||||
[channelKey],
|
||||
);
|
||||
if (!channel.length) throw new Error(`Channel '${channelKey}' not found`);
|
||||
if (channel[0].is_mandatory && !enabled) {
|
||||
throw new Error(`Channel '${channelKey}' is mandatory and cannot be disabled`);
|
||||
}
|
||||
await this.ds.query(
|
||||
`INSERT INTO public.user_notification_preferences (user_id, channel_key, enabled)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, channel_key) DO UPDATE SET enabled = $3, updated_at = NOW()`,
|
||||
[userId, channelKey, enabled],
|
||||
);
|
||||
}
|
||||
|
||||
async setAllUserPreferences(userId: string, preferences: { channelKey: string; enabled: boolean }[]): Promise<void> {
|
||||
for (const pref of preferences) {
|
||||
await this.setUserPreference(userId, pref.channelKey, pref.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
private mapChannel(r: any): NotificationChannel {
|
||||
return {
|
||||
channelKey: r.channel_key,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
isMandatory: r.is_mandatory,
|
||||
isEnabled: r.is_enabled,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Notification, NotificationType, TargetType, TagLogic } from '../../domain/entities/notification.entity';
|
||||
|
||||
export interface CreateNotificationDto {
|
||||
title: string;
|
||||
content: string;
|
||||
type: NotificationType;
|
||||
priority: string;
|
||||
targetType: TargetType;
|
||||
// Targeting payloads
|
||||
tenantIds?: string[]; // SPECIFIC_TENANTS
|
||||
userIds?: string[]; // SPECIFIC_USERS
|
||||
targetTagIds?: string[]; // BY_TENANT_TAG
|
||||
targetTagLogic?: TagLogic; // BY_TENANT_TAG: ANY | ALL
|
||||
targetPlans?: string[]; // BY_PLAN: ['free','pro','enterprise']
|
||||
targetStatuses?: string[]; // BY_TENANT_STATUS: ['active','suspended']
|
||||
targetSegment?: string; // BY_SEGMENT: segment key
|
||||
channelKey?: string | null; // Phase 2 opt-out channel
|
||||
imageUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
requiresForceRead: boolean;
|
||||
isEnabled: boolean;
|
||||
publishedAt?: Date | null;
|
||||
expiresAt?: Date | null;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
export interface NotificationWithReadStatus extends Notification {
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationRepository {
|
||||
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||
|
||||
// ── Write Operations ──────────────────────────────────────────────────────
|
||||
|
||||
async create(dto: CreateNotificationDto): Promise<Notification> {
|
||||
const result = await this.ds.query(
|
||||
`INSERT INTO public.notifications
|
||||
(title, content, type, priority, target_type,
|
||||
target_tag_ids, target_tag_logic, target_plans, target_statuses,
|
||||
target_segment, channel_key,
|
||||
image_url, link_url, requires_force_read, is_enabled,
|
||||
published_at, expires_at, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||||
RETURNING *`,
|
||||
[
|
||||
dto.title, dto.content, dto.type, dto.priority, dto.targetType,
|
||||
dto.targetTagIds?.length ? dto.targetTagIds : null,
|
||||
dto.targetTagLogic ?? 'ANY',
|
||||
dto.targetPlans?.length ? dto.targetPlans : null,
|
||||
dto.targetStatuses?.length ? dto.targetStatuses : null,
|
||||
dto.targetSegment ?? null,
|
||||
dto.channelKey ?? null,
|
||||
dto.imageUrl ?? null, dto.linkUrl ?? null,
|
||||
dto.requiresForceRead, dto.isEnabled,
|
||||
dto.publishedAt ?? null, dto.expiresAt ?? null, dto.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
const row = result[0];
|
||||
|
||||
if (dto.targetType === 'SPECIFIC_TENANTS' && dto.tenantIds?.length) {
|
||||
for (const tid of dto.tenantIds) {
|
||||
await this.ds.query(
|
||||
`INSERT INTO public.notification_tenant_targets (notification_id, tenant_id)
|
||||
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[row.id, tid],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.targetType === 'SPECIFIC_USERS' && dto.userIds?.length) {
|
||||
for (const uid of dto.userIds) {
|
||||
await this.ds.query(
|
||||
`INSERT INTO public.notification_user_targets (notification_id, user_id)
|
||||
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[row.id, uid],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
async update(id: string, dto: Partial<CreateNotificationDto>): Promise<Notification | null> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const add = (col: string, val: any) => { fields.push(`${col} = $${idx++}`); values.push(val); };
|
||||
|
||||
if (dto.title !== undefined) add('title', dto.title);
|
||||
if (dto.content !== undefined) add('content', dto.content);
|
||||
if (dto.type !== undefined) add('type', dto.type);
|
||||
if (dto.priority !== undefined) add('priority', dto.priority);
|
||||
if (dto.targetType !== undefined) add('target_type', dto.targetType);
|
||||
if (dto.targetTagIds !== undefined) add('target_tag_ids', dto.targetTagIds?.length ? dto.targetTagIds : null);
|
||||
if (dto.targetTagLogic !== undefined) add('target_tag_logic', dto.targetTagLogic);
|
||||
if (dto.targetPlans !== undefined) add('target_plans', dto.targetPlans?.length ? dto.targetPlans : null);
|
||||
if (dto.targetStatuses !== undefined) add('target_statuses', dto.targetStatuses?.length ? dto.targetStatuses : null);
|
||||
if (dto.targetSegment !== undefined) add('target_segment', dto.targetSegment);
|
||||
if (dto.channelKey !== undefined) add('channel_key', dto.channelKey);
|
||||
if (dto.imageUrl !== undefined) add('image_url', dto.imageUrl);
|
||||
if (dto.linkUrl !== undefined) add('link_url', dto.linkUrl);
|
||||
if (dto.requiresForceRead !== undefined) add('requires_force_read', dto.requiresForceRead);
|
||||
if (dto.isEnabled !== undefined) add('is_enabled', dto.isEnabled);
|
||||
if (dto.publishedAt !== undefined) add('published_at', dto.publishedAt);
|
||||
if (dto.expiresAt !== undefined) add('expires_at', dto.expiresAt);
|
||||
fields.push(`updated_at = NOW()`);
|
||||
|
||||
if (fields.length === 1) return this.findById(id);
|
||||
|
||||
values.push(id);
|
||||
const result = await this.ds.query(
|
||||
`UPDATE public.notifications SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values,
|
||||
);
|
||||
if (!result.length) return null;
|
||||
|
||||
// Re-sync SPECIFIC_TENANTS targets
|
||||
if (dto.targetType !== undefined || dto.tenantIds !== undefined) {
|
||||
await this.ds.query(`DELETE FROM public.notification_tenant_targets WHERE notification_id = $1`, [id]);
|
||||
const newType = dto.targetType ?? (await this.findById(id))?.targetType;
|
||||
if (newType === 'SPECIFIC_TENANTS' && dto.tenantIds?.length) {
|
||||
for (const tid of dto.tenantIds) {
|
||||
await this.ds.query(
|
||||
`INSERT INTO public.notification_tenant_targets (notification_id, tenant_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[id, tid],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sync SPECIFIC_USERS targets
|
||||
if (dto.targetType !== undefined || dto.userIds !== undefined) {
|
||||
await this.ds.query(`DELETE FROM public.notification_user_targets WHERE notification_id = $1`, [id]);
|
||||
const newType = dto.targetType ?? (await this.findById(id))?.targetType;
|
||||
if (newType === 'SPECIFIC_USERS' && dto.userIds?.length) {
|
||||
for (const uid of dto.userIds) {
|
||||
await this.ds.query(
|
||||
`INSERT INTO public.notification_user_targets (notification_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[id, uid],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.mapRow(result[0]);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.ds.query(`DELETE FROM public.notification_tenant_targets WHERE notification_id = $1`, [id]);
|
||||
await this.ds.query(`DELETE FROM public.notification_user_targets WHERE notification_id = $1`, [id]);
|
||||
await this.ds.query(`DELETE FROM public.notification_reads WHERE notification_id = $1`, [id]);
|
||||
await this.ds.query(`DELETE FROM public.notifications WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Notification | null> {
|
||||
const rows = await this.ds.query(`SELECT * FROM public.notifications WHERE id = $1`, [id]);
|
||||
return rows.length ? this.mapRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async findAllAdmin(params: {
|
||||
type?: string;
|
||||
targetType?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ items: (Notification & { tenantIds: string[]; userIds: string[] })[]; total: number }> {
|
||||
const conds: string[] = [];
|
||||
const baseArgs: any[] = [];
|
||||
let p = 1;
|
||||
if (params.type) { conds.push(`type = $${p++}`); baseArgs.push(params.type); }
|
||||
if (params.targetType) { conds.push(`target_type = $${p++}`); baseArgs.push(params.targetType); }
|
||||
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
|
||||
const [rows, count] = await Promise.all([
|
||||
this.ds.query(
|
||||
`SELECT * FROM public.notifications ${where} ORDER BY created_at DESC LIMIT $${p} OFFSET $${p + 1}`,
|
||||
[...baseArgs, params.limit, params.offset],
|
||||
),
|
||||
this.ds.query(`SELECT COUNT(*)::int AS total FROM public.notifications ${where}`, baseArgs),
|
||||
]);
|
||||
|
||||
const items = await Promise.all(
|
||||
rows.map(async (r: any) => {
|
||||
const [tenants, users] = await Promise.all([
|
||||
this.ds.query(`SELECT tenant_id FROM public.notification_tenant_targets WHERE notification_id = $1`, [r.id]),
|
||||
this.ds.query(`SELECT user_id FROM public.notification_user_targets WHERE notification_id = $1`, [r.id]),
|
||||
]);
|
||||
return {
|
||||
...this.mapRow(r),
|
||||
tenantIds: tenants.map((t: any) => t.tenant_id),
|
||||
userIds: users.map((u: any) => u.user_id),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { items, total: count[0]?.total ?? 0 };
|
||||
}
|
||||
|
||||
// ── User-facing delivery ──────────────────────────────────────────────────
|
||||
|
||||
async findForUser(params: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
tenantPlan?: string;
|
||||
tenantStatus?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ items: NotificationWithReadStatus[]; total: number }> {
|
||||
const { tenantId, userId, tenantPlan, tenantStatus, limit, offset } = params;
|
||||
const targeting = this._targetingSQL();
|
||||
const channelFilter = this._channelFilterSQL();
|
||||
|
||||
const baseWhere = `
|
||||
n.is_enabled = true
|
||||
AND (n.published_at IS NULL OR n.published_at <= NOW())
|
||||
AND (n.expires_at IS NULL OR n.expires_at > NOW())
|
||||
AND ${targeting}
|
||||
AND ${channelFilter}`;
|
||||
|
||||
const [rows, count] = await Promise.all([
|
||||
this.ds.query(
|
||||
`SELECT n.*, CASE WHEN nr.id IS NOT NULL THEN true ELSE false END AS is_read
|
||||
FROM public.notifications n
|
||||
LEFT JOIN public.notification_reads nr ON nr.notification_id = n.id AND nr.user_id = $2
|
||||
WHERE ${baseWhere}
|
||||
ORDER BY n.requires_force_read DESC, n.priority DESC,
|
||||
COALESCE(n.published_at, n.created_at) DESC
|
||||
LIMIT $5 OFFSET $6`,
|
||||
[tenantId, userId, tenantPlan ?? null, tenantStatus ?? null, limit, offset],
|
||||
),
|
||||
this.ds.query(
|
||||
`SELECT COUNT(*)::int AS total FROM public.notifications n WHERE ${baseWhere}`,
|
||||
[tenantId, userId, tenantPlan ?? null, tenantStatus ?? null],
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((r: any) => ({ ...this.mapRow(r), isRead: r.is_read })),
|
||||
total: count[0]?.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getUnreadCount(tenantId: string, userId: string, tenantPlan?: string, tenantStatus?: string): Promise<number> {
|
||||
const targeting = this._targetingSQL();
|
||||
const result = await this.ds.query(
|
||||
`SELECT COUNT(*)::int AS cnt
|
||||
FROM public.notifications n
|
||||
WHERE n.is_enabled = true
|
||||
AND (n.published_at IS NULL OR n.published_at <= NOW())
|
||||
AND (n.expires_at IS NULL OR n.expires_at > NOW())
|
||||
AND ${targeting}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.notification_reads nr
|
||||
WHERE nr.notification_id = n.id AND nr.user_id = $2
|
||||
)`,
|
||||
[tenantId, userId, tenantPlan ?? null, tenantStatus ?? null],
|
||||
);
|
||||
return result[0]?.cnt ?? 0;
|
||||
}
|
||||
|
||||
async markRead(notificationId: string, userId: string, tenantId: string): Promise<void> {
|
||||
await this.ds.query(
|
||||
`INSERT INTO public.notification_reads (notification_id, user_id, tenant_id)
|
||||
VALUES ($1, $2, $3) ON CONFLICT (notification_id, user_id) DO NOTHING`,
|
||||
[notificationId, userId, tenantId],
|
||||
);
|
||||
}
|
||||
|
||||
async markAllRead(tenantId: string, userId: string): Promise<void> {
|
||||
await this.ds.query(
|
||||
`INSERT INTO public.notification_reads (notification_id, user_id, tenant_id)
|
||||
SELECT n.id, $2, $1
|
||||
FROM public.notifications n
|
||||
WHERE n.is_enabled = true
|
||||
AND (n.published_at IS NULL OR n.published_at <= NOW())
|
||||
AND (n.expires_at IS NULL OR n.expires_at > NOW())
|
||||
AND (n.target_type = 'ALL'
|
||||
OR (n.target_type = 'SPECIFIC_TENANTS' AND EXISTS (
|
||||
SELECT 1 FROM public.notification_tenant_targets t
|
||||
WHERE t.notification_id = n.id AND t.tenant_id = $1)))
|
||||
ON CONFLICT (notification_id, user_id) DO NOTHING`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Analytics ─────────────────────────────────────────────────────────────
|
||||
|
||||
async getDeliveryStats(notificationId: string): Promise<{ readCount: number }> {
|
||||
const result = await this.ds.query(
|
||||
`SELECT COUNT(*)::int AS cnt FROM public.notification_reads WHERE notification_id = $1`,
|
||||
[notificationId],
|
||||
);
|
||||
return { readCount: result[0]?.cnt ?? 0 };
|
||||
}
|
||||
|
||||
// ── SQL helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private _targetingSQL(): string {
|
||||
return `(
|
||||
n.target_type = 'ALL'
|
||||
|
||||
OR (n.target_type = 'SPECIFIC_TENANTS' AND EXISTS (
|
||||
SELECT 1 FROM public.notification_tenant_targets t
|
||||
WHERE t.notification_id = n.id AND t.tenant_id = $1))
|
||||
|
||||
OR (n.target_type = 'SPECIFIC_USERS' AND EXISTS (
|
||||
SELECT 1 FROM public.notification_user_targets u
|
||||
WHERE u.notification_id = n.id AND u.user_id = $2))
|
||||
|
||||
OR (n.target_type = 'BY_TENANT_TAG' AND n.target_tag_ids IS NOT NULL AND (
|
||||
CASE WHEN n.target_tag_logic = 'ALL' THEN
|
||||
(SELECT COUNT(*) FROM public.tenant_tag_assignments ta
|
||||
WHERE ta.tenant_id = $1 AND ta.tag_id = ANY(n.target_tag_ids))
|
||||
= array_length(n.target_tag_ids, 1)
|
||||
ELSE
|
||||
EXISTS (SELECT 1 FROM public.tenant_tag_assignments ta
|
||||
WHERE ta.tenant_id = $1 AND ta.tag_id = ANY(n.target_tag_ids))
|
||||
END))
|
||||
|
||||
OR (n.target_type = 'BY_PLAN' AND $3::text IS NOT NULL
|
||||
AND n.target_plans IS NOT NULL AND $3 = ANY(n.target_plans))
|
||||
|
||||
OR (n.target_type = 'BY_TENANT_STATUS' AND $4::text IS NOT NULL
|
||||
AND n.target_statuses IS NOT NULL AND $4 = ANY(n.target_statuses))
|
||||
|
||||
OR (n.target_type = 'BY_SEGMENT' AND EXISTS (
|
||||
SELECT 1 FROM public.notification_segment_members sm
|
||||
WHERE sm.segment_key = n.target_segment AND sm.tenant_id = $1))
|
||||
)`;
|
||||
}
|
||||
|
||||
private _channelFilterSQL(): string {
|
||||
return `(
|
||||
n.channel_key IS NULL
|
||||
OR n.channel_key IN (
|
||||
SELECT c.channel_key FROM public.notification_channels c
|
||||
WHERE c.is_mandatory = true)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM public.user_notification_preferences pref
|
||||
WHERE pref.user_id = $2
|
||||
AND pref.channel_key = n.channel_key
|
||||
AND pref.enabled = false)
|
||||
)`;
|
||||
}
|
||||
|
||||
mapRow(r: any): Notification {
|
||||
return {
|
||||
id: r.id, title: r.title, content: r.content,
|
||||
type: r.type, priority: r.priority, targetType: r.target_type,
|
||||
targetTagIds: r.target_tag_ids, targetTagLogic: r.target_tag_logic ?? 'ANY',
|
||||
targetPlans: r.target_plans, targetStatuses: r.target_statuses,
|
||||
targetSegment: r.target_segment, channelKey: r.channel_key,
|
||||
imageUrl: r.image_url, linkUrl: r.link_url,
|
||||
requiresForceRead: r.requires_force_read, isEnabled: r.is_enabled,
|
||||
publishedAt: r.published_at, expiresAt: r.expires_at,
|
||||
createdBy: r.created_by, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
} as Notification;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export interface SegmentInfo {
|
||||
segmentKey: string;
|
||||
memberCount: number;
|
||||
syncedAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SegmentRepository {
|
||||
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||
|
||||
async listSegments(): Promise<SegmentInfo[]> {
|
||||
const rows = await this.ds.query(
|
||||
`SELECT segment_key, COUNT(*)::int AS member_count, MAX(synced_at) AS synced_at
|
||||
FROM public.notification_segment_members
|
||||
GROUP BY segment_key
|
||||
ORDER BY segment_key`,
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
segmentKey: r.segment_key,
|
||||
memberCount: r.member_count,
|
||||
syncedAt: r.synced_at,
|
||||
}));
|
||||
}
|
||||
|
||||
async getSegmentMembers(
|
||||
segmentKey: string,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
): Promise<{ items: { tenantId: string; syncedAt: Date }[]; total: number }> {
|
||||
const [rows, count] = await Promise.all([
|
||||
this.ds.query(
|
||||
`SELECT tenant_id, synced_at FROM public.notification_segment_members
|
||||
WHERE segment_key = $1 ORDER BY synced_at DESC LIMIT $2 OFFSET $3`,
|
||||
[segmentKey, limit, offset],
|
||||
),
|
||||
this.ds.query(
|
||||
`SELECT COUNT(*)::int AS total FROM public.notification_segment_members WHERE segment_key = $1`,
|
||||
[segmentKey],
|
||||
),
|
||||
]);
|
||||
return {
|
||||
items: rows.map((r: any) => ({ tenantId: r.tenant_id, syncedAt: r.synced_at })),
|
||||
total: count[0]?.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bulk upsert: replace the entire segment membership */
|
||||
async syncSegment(segmentKey: string, tenantIds: string[]): Promise<void> {
|
||||
await this.ds.query(
|
||||
`DELETE FROM public.notification_segment_members WHERE segment_key = $1`,
|
||||
[segmentKey],
|
||||
);
|
||||
if (tenantIds.length === 0) return;
|
||||
// Batch insert using VALUES
|
||||
const values = tenantIds.map((tid, i) => `($1, $${i + 2}, NOW())`).join(', ');
|
||||
await this.ds.query(
|
||||
`INSERT INTO public.notification_segment_members (segment_key, tenant_id, synced_at)
|
||||
VALUES ${values} ON CONFLICT DO NOTHING`,
|
||||
[segmentKey, ...tenantIds],
|
||||
);
|
||||
}
|
||||
|
||||
/** Incremental add/remove */
|
||||
async addToSegment(segmentKey: string, tenantIds: string[]): Promise<void> {
|
||||
for (const tid of tenantIds) {
|
||||
await this.ds.query(
|
||||
`INSERT INTO public.notification_segment_members (segment_key, tenant_id)
|
||||
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[segmentKey, tid],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async removeFromSegment(segmentKey: string, tenantIds: string[]): Promise<void> {
|
||||
if (!tenantIds.length) return;
|
||||
await this.ds.query(
|
||||
`DELETE FROM public.notification_segment_members
|
||||
WHERE segment_key = $1 AND tenant_id = ANY($2::text[])`,
|
||||
[segmentKey, tenantIds],
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSegment(segmentKey: string): Promise<void> {
|
||||
await this.ds.query(
|
||||
`DELETE FROM public.notification_segment_members WHERE segment_key = $1`,
|
||||
[segmentKey],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
} from '@nestjs/common';
|
||||
import { CampaignRepository, CreateCampaignDto } from '../../../infrastructure/repositories/campaign.repository';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* Platform-admin Campaign management endpoints.
|
||||
* A campaign wraps a notification + schedule logic (ONCE or RECURRING cron).
|
||||
*/
|
||||
@Controller('api/v1/notifications/campaigns')
|
||||
export class CampaignAdminController {
|
||||
constructor(private readonly repo: CampaignRepository) {}
|
||||
|
||||
/** POST /api/v1/notifications/campaigns — create */
|
||||
@Post()
|
||||
async create(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() body: any,
|
||||
) {
|
||||
const admin = this.requireAdmin(auth);
|
||||
return this.repo.create({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
notificationId: body.notificationId,
|
||||
scheduleType: body.scheduleType ?? 'ONCE',
|
||||
scheduledAt: body.scheduledAt ? new Date(body.scheduledAt) : null,
|
||||
cronExpr: body.cronExpr ?? null,
|
||||
timezone: body.timezone ?? 'UTC',
|
||||
createdBy: admin.userId,
|
||||
} as CreateCampaignDto);
|
||||
}
|
||||
|
||||
/** GET /api/v1/notifications/campaigns — list */
|
||||
@Get()
|
||||
async list(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('status') status: string | undefined,
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
return this.repo.findAll({ status, limit: Math.min(limit, 100), offset });
|
||||
}
|
||||
|
||||
/** GET /api/v1/notifications/campaigns/:id — detail */
|
||||
@Get(':id')
|
||||
async getOne(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
const item = await this.repo.findById(id);
|
||||
if (!item) throw new NotFoundException('Campaign not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
/** GET /api/v1/notifications/campaigns/:id/stats — analytics */
|
||||
@Get(':id/stats')
|
||||
async getStats(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
const item = await this.repo.findById(id);
|
||||
if (!item) throw new NotFoundException('Campaign not found');
|
||||
return this.repo.getStats(id);
|
||||
}
|
||||
|
||||
/** PUT /api/v1/notifications/campaigns/:id — update */
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: any,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
const dto: any = {};
|
||||
if (body.name !== undefined) dto.name = body.name;
|
||||
if (body.description !== undefined) dto.description = body.description;
|
||||
if (body.notificationId !== undefined) dto.notificationId = body.notificationId;
|
||||
if (body.scheduleType !== undefined) dto.scheduleType = body.scheduleType;
|
||||
if (body.scheduledAt !== undefined) dto.scheduledAt = body.scheduledAt ? new Date(body.scheduledAt) : null;
|
||||
if (body.cronExpr !== undefined) dto.cronExpr = body.cronExpr;
|
||||
if (body.timezone !== undefined) dto.timezone = body.timezone;
|
||||
|
||||
const updated = await this.repo.update(id, dto);
|
||||
if (!updated) throw new NotFoundException('Campaign not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** POST /api/v1/notifications/campaigns/:id/schedule — schedule / re-schedule */
|
||||
@Post(':id/schedule')
|
||||
async schedule(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { scheduledAt?: string; cronExpr?: string; timezone?: string },
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
const campaign = await this.repo.findById(id);
|
||||
if (!campaign) throw new NotFoundException('Campaign not found');
|
||||
|
||||
const update: any = { status: 'SCHEDULED' };
|
||||
if (body.scheduledAt) {
|
||||
update.scheduledAt = new Date(body.scheduledAt);
|
||||
update.nextRunAt = new Date(body.scheduledAt);
|
||||
}
|
||||
if (body.cronExpr !== undefined) update.cronExpr = body.cronExpr;
|
||||
if (body.timezone !== undefined) update.timezone = body.timezone;
|
||||
|
||||
return this.repo.update(id, update);
|
||||
}
|
||||
|
||||
/** POST /api/v1/notifications/campaigns/:id/cancel — cancel */
|
||||
@Post(':id/cancel')
|
||||
async cancel(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
const updated = await this.repo.update(id, { status: 'CANCELLED' });
|
||||
if (!updated) throw new NotFoundException('Campaign not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/notifications/campaigns/:id — delete DRAFT campaigns only */
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
const campaign = await this.repo.findById(id);
|
||||
if (!campaign) throw new NotFoundException('Campaign not found');
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
|
||||
private requireAdmin(auth: string): { userId: string } {
|
||||
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException('Missing authorization header');
|
||||
const token = auth.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret') as any;
|
||||
const roles: string[] = Array.isArray(payload.roles) ? payload.roles : [];
|
||||
if (!roles.includes('platform_admin') && !roles.includes('platform_super_admin')) {
|
||||
throw new UnauthorizedException('Admin role required');
|
||||
}
|
||||
return { userId: payload.sub };
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) throw err;
|
||||
throw new UnauthorizedException('Invalid JWT');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { NotificationRepository, CreateNotificationDto } from '../../../infrastructure/repositories/notification.repository';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* Platform-admin endpoints for managing notifications.
|
||||
* Requires JWT with platform_admin or platform_super_admin role.
|
||||
*/
|
||||
@Controller('api/v1/notifications/admin')
|
||||
export class NotificationAdminController {
|
||||
constructor(private readonly repo: NotificationRepository) {}
|
||||
|
||||
/** POST /api/v1/notifications/admin — create notification */
|
||||
@Post()
|
||||
async create(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() body: any,
|
||||
) {
|
||||
const admin = this.requireAdmin(auth);
|
||||
return this.repo.create({
|
||||
title: body.title,
|
||||
content: body.content,
|
||||
type: body.type ?? 'ANNOUNCEMENT',
|
||||
priority: body.priority ?? 'NORMAL',
|
||||
targetType: body.targetType ?? 'ALL',
|
||||
tenantIds: body.tenantIds,
|
||||
userIds: body.userIds,
|
||||
targetTagIds: body.targetTagIds,
|
||||
targetTagLogic: body.targetTagLogic ?? 'ANY',
|
||||
targetPlans: body.targetPlans,
|
||||
targetStatuses: body.targetStatuses,
|
||||
targetSegment: body.targetSegment ?? null,
|
||||
channelKey: body.channelKey ?? null,
|
||||
imageUrl: body.imageUrl ?? null,
|
||||
linkUrl: body.linkUrl ?? null,
|
||||
requiresForceRead: body.requiresForceRead ?? false,
|
||||
isEnabled: body.isEnabled ?? true,
|
||||
publishedAt: body.publishedAt ? new Date(body.publishedAt) : null,
|
||||
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
|
||||
createdBy: admin.userId,
|
||||
} as CreateNotificationDto);
|
||||
}
|
||||
|
||||
/** GET /api/v1/notifications/admin — list all notifications */
|
||||
@Get()
|
||||
async list(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('type') type: string | undefined,
|
||||
@Query('targetType') targetType: string | undefined,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
return this.repo.findAllAdmin({ type, targetType, limit: Math.min(limit, 200), offset });
|
||||
}
|
||||
|
||||
/** GET /api/v1/notifications/admin/:id — get detail */
|
||||
@Get(':id')
|
||||
async getOne(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
const item = await this.repo.findById(id);
|
||||
if (!item) throw new NotFoundException('Notification not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
/** PUT /api/v1/notifications/admin/:id — update */
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: any,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
const dto: Partial<CreateNotificationDto> = {};
|
||||
if (body.title !== undefined) dto.title = body.title;
|
||||
if (body.content !== undefined) dto.content = body.content;
|
||||
if (body.type !== undefined) dto.type = body.type;
|
||||
if (body.priority !== undefined) dto.priority = body.priority;
|
||||
if (body.targetType !== undefined) dto.targetType = body.targetType;
|
||||
if (body.tenantIds !== undefined) dto.tenantIds = body.tenantIds;
|
||||
if (body.userIds !== undefined) dto.userIds = body.userIds;
|
||||
if (body.targetTagIds !== undefined) dto.targetTagIds = body.targetTagIds;
|
||||
if (body.targetTagLogic !== undefined) dto.targetTagLogic = body.targetTagLogic;
|
||||
if (body.targetPlans !== undefined) dto.targetPlans = body.targetPlans;
|
||||
if (body.targetStatuses !== undefined) dto.targetStatuses = body.targetStatuses;
|
||||
if (body.targetSegment !== undefined) dto.targetSegment = body.targetSegment;
|
||||
if (body.channelKey !== undefined) dto.channelKey = body.channelKey;
|
||||
if (body.imageUrl !== undefined) dto.imageUrl = body.imageUrl;
|
||||
if (body.linkUrl !== undefined) dto.linkUrl = body.linkUrl;
|
||||
if (body.requiresForceRead !== undefined) dto.requiresForceRead = body.requiresForceRead;
|
||||
if (body.isEnabled !== undefined) dto.isEnabled = body.isEnabled;
|
||||
if (body.publishedAt !== undefined) dto.publishedAt = body.publishedAt ? new Date(body.publishedAt) : null;
|
||||
if (body.expiresAt !== undefined) dto.expiresAt = body.expiresAt ? new Date(body.expiresAt) : null;
|
||||
|
||||
const updated = await this.repo.update(id, dto);
|
||||
if (!updated) throw new NotFoundException('Notification not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/notifications/admin/:id */
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
const item = await this.repo.findById(id);
|
||||
if (!item) throw new NotFoundException('Notification not found');
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
|
||||
private requireAdmin(auth: string): { userId: string } {
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing authorization header');
|
||||
}
|
||||
const token = auth.slice(7);
|
||||
const secret = process.env.JWT_SECRET || 'dev-secret';
|
||||
try {
|
||||
const payload = jwt.verify(token, secret) as any;
|
||||
const roles: string[] = Array.isArray(payload.roles) ? payload.roles : [];
|
||||
if (!roles.includes('platform_admin') && !roles.includes('platform_super_admin')) {
|
||||
throw new UnauthorizedException('Admin role required');
|
||||
}
|
||||
return { userId: payload.sub };
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) throw err;
|
||||
throw new UnauthorizedException('Invalid JWT');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ChannelRepository } from '../../../infrastructure/repositories/channel.repository';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* Notification channel management.
|
||||
* Admin endpoints: POST/PUT/DELETE require platform_admin role.
|
||||
* Public GET (list channels) is accessible to any authenticated user (for preferences UI).
|
||||
*/
|
||||
@Controller('api/v1/notifications/channels')
|
||||
export class NotificationChannelController {
|
||||
constructor(private readonly repo: ChannelRepository) {}
|
||||
|
||||
/** GET /api/v1/notifications/channels — list all enabled channels */
|
||||
@Get()
|
||||
async listChannels(@Headers('authorization') auth: string) {
|
||||
this.extractUser(auth); // any valid JWT
|
||||
return this.repo.listChannels();
|
||||
}
|
||||
|
||||
/** POST /api/v1/notifications/channels — create channel (admin only) */
|
||||
@Post()
|
||||
async createChannel(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() body: { channelKey: string; name: string; description?: string; isMandatory?: boolean },
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
if (!body.channelKey?.match(/^[a-z0-9_]+$/)) {
|
||||
throw new BadRequestException('channelKey must be lowercase alphanumeric with underscores');
|
||||
}
|
||||
return this.repo.createChannel(body);
|
||||
}
|
||||
|
||||
/** PUT /api/v1/notifications/channels/:key — update channel (admin only) */
|
||||
@Put(':key')
|
||||
async updateChannel(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('key') key: string,
|
||||
@Body() body: { name?: string; description?: string; isMandatory?: boolean; isEnabled?: boolean },
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
const result = await this.repo.updateChannel(key, body);
|
||||
if (!result) throw new NotFoundException('Channel not found');
|
||||
return result;
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/notifications/channels/:key — delete channel (admin only) */
|
||||
@Delete(':key')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteChannel(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('key') key: string,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
await this.repo.deleteChannel(key);
|
||||
}
|
||||
|
||||
// ── User preferences ──────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/v1/notifications/channels/me/preferences */
|
||||
@Get('me/preferences')
|
||||
async getMyPreferences(@Headers('authorization') auth: string) {
|
||||
const { userId } = this.extractUser(auth);
|
||||
return this.repo.getUserPreferences(userId);
|
||||
}
|
||||
|
||||
/** PUT /api/v1/notifications/channels/me/preferences — bulk update user preferences */
|
||||
@Put('me/preferences')
|
||||
async updateMyPreferences(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() body: { preferences: { channelKey: string; enabled: boolean }[] },
|
||||
) {
|
||||
const { userId } = this.extractUser(auth);
|
||||
await this.repo.setAllUserPreferences(userId, body.preferences ?? []);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** PUT /api/v1/notifications/channels/me/preferences/:key — update single preference */
|
||||
@Put('me/preferences/:key')
|
||||
async updateOnePreference(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('key') key: string,
|
||||
@Body() body: { enabled: boolean },
|
||||
) {
|
||||
const { userId } = this.extractUser(auth);
|
||||
try {
|
||||
await this.repo.setUserPreference(userId, key, body.enabled);
|
||||
} catch (e: any) {
|
||||
throw new BadRequestException(e.message);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private extractUser(auth: string): { userId: string } {
|
||||
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException('Missing authorization header');
|
||||
const token = auth.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret') as any;
|
||||
return { userId: payload.sub };
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid JWT');
|
||||
}
|
||||
}
|
||||
|
||||
private requireAdmin(auth: string): void {
|
||||
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException('Missing authorization header');
|
||||
const token = auth.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret') as any;
|
||||
const roles: string[] = Array.isArray(payload.roles) ? payload.roles : [];
|
||||
if (!roles.includes('platform_admin') && !roles.includes('platform_super_admin')) {
|
||||
throw new UnauthorizedException('Admin role required');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) throw err;
|
||||
throw new UnauthorizedException('Invalid JWT');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
} from '@nestjs/common';
|
||||
import { NotificationRepository } from '../../../infrastructure/repositories/notification.repository';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* User-facing notification endpoints.
|
||||
* Kong enforces JWT — we extract tenantId/userId from Authorization header.
|
||||
*/
|
||||
@Controller('api/v1/notifications')
|
||||
export class NotificationUserController {
|
||||
constructor(private readonly repo: NotificationRepository) {}
|
||||
|
||||
/** GET /api/v1/notifications/me — notifications visible to this user */
|
||||
@Get('me')
|
||||
async getMyNotifications(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
const { tenantId, userId } = this.extractJwt(auth);
|
||||
return this.repo.findForUser({ tenantId, userId, limit: Math.min(limit, 100), offset });
|
||||
}
|
||||
|
||||
/** GET /api/v1/notifications/me/unread-count */
|
||||
@Get('me/unread-count')
|
||||
async getUnreadCount(@Headers('authorization') auth: string) {
|
||||
const { tenantId, userId } = this.extractJwt(auth);
|
||||
const count = await this.repo.getUnreadCount(tenantId, userId);
|
||||
return { count };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/notifications/me/mark-read
|
||||
* Body: { notificationId?: string } — omit to mark ALL as read
|
||||
*/
|
||||
@Post('me/mark-read')
|
||||
async markRead(
|
||||
@Headers('authorization') auth: string,
|
||||
@Body() body: { notificationId?: string },
|
||||
) {
|
||||
const { tenantId, userId } = this.extractJwt(auth);
|
||||
if (body?.notificationId) {
|
||||
await this.repo.markRead(body.notificationId, userId, tenantId);
|
||||
} else {
|
||||
await this.repo.markAllRead(tenantId, userId);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private extractJwt(auth: string): { tenantId: string; userId: string } {
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing authorization header');
|
||||
}
|
||||
const token = auth.slice(7);
|
||||
const secret = process.env.JWT_SECRET || 'dev-secret';
|
||||
try {
|
||||
const payload = jwt.verify(token, secret) as any;
|
||||
return { tenantId: payload.tenantId, userId: payload.sub };
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid JWT');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { SegmentRepository } from '../../../infrastructure/repositories/segment.repository';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* Platform-admin Segment management endpoints.
|
||||
* Segments are pre-defined audience groups that can be used for BY_SEGMENT targeting.
|
||||
*
|
||||
* Platform admins manually or via ETL populate segment membership.
|
||||
* Common segments: trial_ending_soon, high_value, at_risk, new_users, etc.
|
||||
*/
|
||||
@Controller('api/v1/notifications/segments')
|
||||
export class SegmentAdminController {
|
||||
constructor(private readonly repo: SegmentRepository) {}
|
||||
|
||||
/** GET /api/v1/notifications/segments — list all segments with member counts */
|
||||
@Get()
|
||||
async listSegments(@Headers('authorization') auth: string) {
|
||||
this.requireAdmin(auth);
|
||||
return this.repo.listSegments();
|
||||
}
|
||||
|
||||
/** GET /api/v1/notifications/segments/:key/members — paginated member list */
|
||||
@Get(':key/members')
|
||||
async getMembers(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('key') key: string,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
return this.repo.getSegmentMembers(key, Math.min(limit, 500), offset);
|
||||
}
|
||||
|
||||
/** POST /api/v1/notifications/segments/:key/sync — replace entire segment (bulk ETL) */
|
||||
@Post(':key/sync')
|
||||
async syncSegment(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('key') key: string,
|
||||
@Body() body: { tenantIds: string[] },
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
await this.repo.syncSegment(key, body.tenantIds ?? []);
|
||||
return { ok: true, count: body.tenantIds?.length ?? 0 };
|
||||
}
|
||||
|
||||
/** POST /api/v1/notifications/segments/:key/add — add tenants to segment */
|
||||
@Post(':key/add')
|
||||
async addToSegment(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('key') key: string,
|
||||
@Body() body: { tenantIds: string[] },
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
await this.repo.addToSegment(key, body.tenantIds ?? []);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** POST /api/v1/notifications/segments/:key/remove — remove tenants from segment */
|
||||
@Post(':key/remove')
|
||||
async removeFromSegment(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('key') key: string,
|
||||
@Body() body: { tenantIds: string[] },
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
await this.repo.removeFromSegment(key, body.tenantIds ?? []);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/notifications/segments/:key — delete entire segment */
|
||||
@Delete(':key')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteSegment(
|
||||
@Headers('authorization') auth: string,
|
||||
@Param('key') key: string,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
await this.repo.deleteSegment(key);
|
||||
}
|
||||
|
||||
private requireAdmin(auth: string): void {
|
||||
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException('Missing authorization header');
|
||||
const token = auth.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret') as any;
|
||||
const roles: string[] = Array.isArray(payload.roles) ? payload.roles : [];
|
||||
if (!roles.includes('platform_admin') && !roles.includes('platform_super_admin')) {
|
||||
throw new UnauthorizedException('Admin role required');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) throw err;
|
||||
throw new UnauthorizedException('Invalid JWT');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NotificationModule } from './notification.module';
|
||||
|
||||
const logger = new Logger('NotificationService');
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error(`Unhandled Rejection: ${reason}`);
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error(`Uncaught Exception: ${error.message}`, error.stack);
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(NotificationModule);
|
||||
|
||||
const config = app.get(ConfigService);
|
||||
const port = config.get<number>('NOTIFICATION_SERVICE_PORT', 3013);
|
||||
|
||||
app.enableCors();
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`notification-service running on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
logger.error(`Failed to start notification-service: ${err.message}`, err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DatabaseModule } from '@it0/database';
|
||||
|
||||
// Domain Entities
|
||||
import { Notification } from './domain/entities/notification.entity';
|
||||
import { NotificationRead } from './domain/entities/notification-read.entity';
|
||||
import { NotificationTenantTarget } from './domain/entities/notification-tenant-target.entity';
|
||||
|
||||
// Infrastructure
|
||||
import { NotificationRepository } from './infrastructure/repositories/notification.repository';
|
||||
import { ChannelRepository } from './infrastructure/repositories/channel.repository';
|
||||
import { CampaignRepository } from './infrastructure/repositories/campaign.repository';
|
||||
import { SegmentRepository } from './infrastructure/repositories/segment.repository';
|
||||
|
||||
// Application Services
|
||||
import { EventTriggerService } from './application/services/event-trigger.service';
|
||||
|
||||
// Controllers
|
||||
import { NotificationAdminController } from './interfaces/rest/controllers/notification-admin.controller';
|
||||
import { NotificationUserController } from './interfaces/rest/controllers/notification-user.controller';
|
||||
import { NotificationChannelController } from './interfaces/rest/controllers/notification-channel.controller';
|
||||
import { CampaignAdminController } from './interfaces/rest/controllers/campaign-admin.controller';
|
||||
import { SegmentAdminController } from './interfaces/rest/controllers/segment-admin.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
DatabaseModule.forRoot(),
|
||||
TypeOrmModule.forFeature([Notification, NotificationRead, NotificationTenantTarget]),
|
||||
],
|
||||
controllers: [
|
||||
NotificationAdminController,
|
||||
NotificationUserController,
|
||||
NotificationChannelController,
|
||||
CampaignAdminController,
|
||||
SegmentAdminController,
|
||||
],
|
||||
providers: [
|
||||
NotificationRepository,
|
||||
ChannelRepository,
|
||||
CampaignRepository,
|
||||
SegmentRepository,
|
||||
EventTriggerService,
|
||||
],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"baseUrl": ".",
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"strictPropertyInitialization": false,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"paths": {
|
||||
"@it0/common": ["../../shared/common/src"],
|
||||
"@it0/common/*": ["../../shared/common/src/*"],
|
||||
"@it0/database": ["../../shared/database/src"],
|
||||
"@it0/database/*": ["../../shared/database/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
-- ============================================================
|
||||
-- Migration 007: Notification System Tables
|
||||
-- All tables in public schema (platform-wide, cross-tenant)
|
||||
-- ============================================================
|
||||
|
||||
-- 1. Notifications — created & managed by platform admins
|
||||
CREATE TABLE IF NOT EXISTS public.notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
type VARCHAR(30) NOT NULL DEFAULT 'ANNOUNCEMENT',
|
||||
-- SYSTEM | MAINTENANCE | FEATURE | ANNOUNCEMENT | BILLING | SECURITY
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'NORMAL',
|
||||
-- LOW | NORMAL | HIGH | URGENT
|
||||
target_type VARCHAR(20) NOT NULL DEFAULT 'ALL',
|
||||
-- ALL | SPECIFIC_TENANTS
|
||||
image_url VARCHAR(500),
|
||||
link_url VARCHAR(500),
|
||||
requires_force_read BOOLEAN NOT NULL DEFAULT false,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
published_at TIMESTAMPTZ, -- NULL = draft (not yet published)
|
||||
expires_at TIMESTAMPTZ, -- NULL = never expires
|
||||
created_by VARCHAR(100),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_type ON public.notifications (type);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_enabled ON public.notifications (is_enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_published ON public.notifications (published_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_target ON public.notifications (target_type);
|
||||
|
||||
-- 2. Notification reads — tracks per-user read status
|
||||
CREATE TABLE IF NOT EXISTS public.notification_reads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
notification_id UUID NOT NULL REFERENCES public.notifications(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
tenant_id VARCHAR(100) NOT NULL,
|
||||
read_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (notification_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON public.notification_reads (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_reads_notif ON public.notification_reads (notification_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_reads_tenant ON public.notification_reads (tenant_id);
|
||||
|
||||
-- 3. Notification tenant targets — for SPECIFIC_TENANTS targeting
|
||||
CREATE TABLE IF NOT EXISTS public.notification_tenant_targets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
notification_id UUID NOT NULL REFERENCES public.notifications(id) ON DELETE CASCADE,
|
||||
tenant_id VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notif_tenant_targets_notif ON public.notification_tenant_targets (notification_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notif_tenant_targets_tenant ON public.notification_tenant_targets (tenant_id);
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
-- ============================================================
|
||||
-- Migration 008: Tenant Tag System + Notification Targeting Extensions
|
||||
-- Phase 1: Precision push targeting foundation
|
||||
-- ============================================================
|
||||
|
||||
-- 1. Tenant tag definitions (platform-level, managed by platform admins)
|
||||
CREATE TABLE IF NOT EXISTS public.tenant_tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(50) NOT NULL UNIQUE, -- e.g. "高价值客户", "试用期", "即将流失"
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#6366F1',
|
||||
description TEXT,
|
||||
created_by VARCHAR(100),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 2. Tenant ← → Tag assignments
|
||||
CREATE TABLE IF NOT EXISTS public.tenant_tag_assignments (
|
||||
tenant_id VARCHAR(100) NOT NULL,
|
||||
tag_id UUID NOT NULL REFERENCES public.tenant_tags(id) ON DELETE CASCADE,
|
||||
tagged_by VARCHAR(100),
|
||||
tagged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tta_tenant ON public.tenant_tag_assignments (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tta_tag ON public.tenant_tag_assignments (tag_id);
|
||||
|
||||
-- 3. Notification user-level targets (for SPECIFIC_USERS targeting)
|
||||
CREATE TABLE IF NOT EXISTS public.notification_user_targets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
notification_id UUID NOT NULL REFERENCES public.notifications(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notif_user_targets_notif ON public.notification_user_targets (notification_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notif_user_targets_user ON public.notification_user_targets (user_id);
|
||||
|
||||
-- 4. Extend notifications table with new targeting columns
|
||||
ALTER TABLE public.notifications
|
||||
ADD COLUMN IF NOT EXISTS target_tag_ids UUID[] DEFAULT NULL,
|
||||
-- Tag IDs for BY_TENANT_TAG targeting
|
||||
ADD COLUMN IF NOT EXISTS target_tag_logic VARCHAR(3) DEFAULT 'ANY',
|
||||
-- ANY: tenant has at least one tag; ALL: tenant has all tags
|
||||
ADD COLUMN IF NOT EXISTS target_plans TEXT[] DEFAULT NULL,
|
||||
-- ['free','pro','enterprise'] for BY_PLAN targeting
|
||||
ADD COLUMN IF NOT EXISTS target_statuses TEXT[] DEFAULT NULL,
|
||||
-- ['active','suspended'] for BY_TENANT_STATUS targeting
|
||||
ADD COLUMN IF NOT EXISTS channel_key VARCHAR(50) DEFAULT NULL;
|
||||
-- References notification_channels.key (Phase 2, nullable for now)
|
||||
|
||||
-- 5. Extended TargetType values (documentation via check constraint)
|
||||
-- Existing: ALL, SPECIFIC_TENANTS
|
||||
-- New: BY_TENANT_TAG, BY_PLAN, BY_TENANT_STATUS, SPECIFIC_USERS
|
||||
-- Note: constraint not enforced to allow backward compat; validated in service layer
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
-- ============================================================
|
||||
-- Migration 009: Notification Channels + User Preferences
|
||||
-- Phase 2: Channel-based opt-out system
|
||||
-- ============================================================
|
||||
|
||||
-- 1. Notification channels (platform-managed)
|
||||
-- Platform defines channels like "billing", "security", "marketing"
|
||||
-- Users can opt out of non-mandatory channels
|
||||
CREATE TABLE IF NOT EXISTS public.notification_channels (
|
||||
channel_key VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
is_mandatory BOOLEAN NOT NULL DEFAULT false,
|
||||
-- mandatory=true: users cannot opt out (e.g. security alerts)
|
||||
-- mandatory=false: users can disable (e.g. marketing)
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Seed default channels
|
||||
INSERT INTO public.notification_channels (channel_key, name, description, is_mandatory) VALUES
|
||||
('system', '系统通知', '平台级系统公告与维护通知', true),
|
||||
('security', '安全告警', '账号安全、异常登录等安全相关通知', true),
|
||||
('billing', '账单通知', '订阅续费、发票、扣款通知', false),
|
||||
('feature', '新功能', '产品新功能与版本更新通知', false),
|
||||
('marketing', '营销推广', '促销活动、优惠券、活动邀请', false),
|
||||
('ops', '运维报警', '服务器异常、告警触发通知', false)
|
||||
ON CONFLICT (channel_key) DO NOTHING;
|
||||
|
||||
-- 2. User notification preferences (per-user opt-out per channel)
|
||||
-- Only non-mandatory channels can be opted out
|
||||
CREATE TABLE IF NOT EXISTS public.user_notification_preferences (
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
channel_key VARCHAR(50) NOT NULL REFERENCES public.notification_channels(channel_key) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, channel_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_unp_user ON public.user_notification_preferences (user_id);
|
||||
|
||||
-- 3. Notification segment members (for BY_SEGMENT targeting, Phase 4)
|
||||
-- Platform populates this table via ETL/cron jobs
|
||||
CREATE TABLE IF NOT EXISTS public.notification_segment_members (
|
||||
segment_key VARCHAR(100) NOT NULL,
|
||||
tenant_id VARCHAR(100) NOT NULL,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (segment_key, tenant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nsm_segment ON public.notification_segment_members (segment_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_nsm_tenant ON public.notification_segment_members (tenant_id);
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
-- ============================================================
|
||||
-- Migration 010: Notification Campaigns + Read Event Log
|
||||
-- Phase 3: Campaign scheduling + analytics
|
||||
-- ============================================================
|
||||
|
||||
-- 1. Notification campaigns
|
||||
-- A campaign is a scheduled or recurring notification push job
|
||||
CREATE TABLE IF NOT EXISTS public.notification_campaigns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||
-- DRAFT | SCHEDULED | RUNNING | COMPLETED | CANCELLED | FAILED
|
||||
|
||||
-- Linked notification template (optional: can create ad-hoc body)
|
||||
notification_id UUID REFERENCES public.notifications(id) ON DELETE SET NULL,
|
||||
|
||||
-- Schedule
|
||||
schedule_type VARCHAR(20) NOT NULL DEFAULT 'ONCE',
|
||||
-- ONCE | RECURRING
|
||||
scheduled_at TIMESTAMPTZ, -- for ONCE: exact send time
|
||||
cron_expr VARCHAR(100), -- for RECURRING: cron expression
|
||||
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
|
||||
next_run_at TIMESTAMPTZ, -- computed by scheduler
|
||||
last_run_at TIMESTAMPTZ,
|
||||
|
||||
-- Execution stats
|
||||
target_count INT NOT NULL DEFAULT 0, -- estimated / actual target size
|
||||
sent_count INT NOT NULL DEFAULT 0,
|
||||
read_count INT NOT NULL DEFAULT 0,
|
||||
|
||||
created_by VARCHAR(100),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_campaigns_status ON public.notification_campaigns (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_campaigns_scheduled_at ON public.notification_campaigns (scheduled_at)
|
||||
WHERE status IN ('SCHEDULED', 'RUNNING');
|
||||
CREATE INDEX IF NOT EXISTS idx_campaigns_next_run ON public.notification_campaigns (next_run_at)
|
||||
WHERE status = 'SCHEDULED';
|
||||
|
||||
-- 2. Campaign execution log
|
||||
-- Records each run of a campaign (for RECURRING campaigns, one row per run)
|
||||
CREATE TABLE IF NOT EXISTS public.campaign_execution_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
campaign_id UUID NOT NULL REFERENCES public.notification_campaigns(id) ON DELETE CASCADE,
|
||||
notification_id UUID REFERENCES public.notifications(id) ON DELETE SET NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
|
||||
-- RUNNING | COMPLETED | FAILED
|
||||
target_count INT NOT NULL DEFAULT 0,
|
||||
sent_count INT NOT NULL DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cel_campaign ON public.campaign_execution_log (campaign_id);
|
||||
|
||||
-- 3. Notification delivery event log
|
||||
-- Detailed per-notification read events (for analytics / funnel)
|
||||
CREATE TABLE IF NOT EXISTS public.notification_event_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
notification_id UUID NOT NULL REFERENCES public.notifications(id) ON DELETE CASCADE,
|
||||
campaign_id UUID REFERENCES public.notification_campaigns(id) ON DELETE SET NULL,
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
-- DELIVERED | READ | CLICKED | DISMISSED
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
tenant_id VARCHAR(100) NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
metadata JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nel_notification ON public.notification_event_log (notification_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_nel_campaign ON public.notification_event_log (campaign_id) WHERE campaign_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_nel_user ON public.notification_event_log (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_nel_occurred ON public.notification_event_log (occurred_at);
|
||||
Loading…
Reference in New Issue