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:
hailin 2026-03-07 22:33:40 -08:00
parent 3020ecc465
commit 5ff8bda99e
48 changed files with 5879 additions and 4 deletions

View File

@ -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).

View File

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

View File

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

View File

@ -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&#10;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&#10;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>
);
}

View File

@ -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&#10;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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -33,6 +33,11 @@
"billingInvoices": "账单列表",
"appVersions": "App 版本管理",
"referral": "推荐管理",
"tenantTags": "租户标签",
"notificationChannels": "通知频道",
"campaigns": "推送活动",
"segments": "人群包",
"notifications": "消息管理",
"tenants": "租户",
"users": "用户",
"settings": "设置",

View File

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

View File

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

View File

@ -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} /> },
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('确认已读'),
),
],
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"]
}

View File

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

View File

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

View File

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

View File

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