From 4df699348f5c5d2e89380d60e32c277555984d6b Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 8 Mar 2026 00:18:17 -0800 Subject: [PATCH] feat(referral): add user-level personal circle + points system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 011: 4 new tables (user_referral_codes, user_referral_relationships, user_point_transactions, user_point_balances) - Referral service: user-level repositories, use cases, and controller endpoints (GET /me/user, /me/circle, /me/points; POST /internal/user-register) - Admin endpoints: user-circles, user-points, user-balances listing - Auth service: fire-and-forget user referral registration on signup - Flutter: 2-tab UI (企业推荐 / 我的圈子) with personal code card, points balance, circle member list, and points history - Web admin: 2 new tabs (用户圈子 / 用户积分) with transaction ledger and balance leaderboard Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/(admin)/referral/page.tsx | 320 +++++-- it0-web-admin/src/domain/entities/referral.ts | 49 ++ .../repositories/api-referral.repository.ts | 51 +- .../referral/data/referral_repository.dart | 43 + .../referral/domain/models/referral_info.dart | 103 +++ .../providers/referral_providers.dart | 33 +- .../presentation/screens/referral_screen.dart | 809 +++++++++++++++--- .../src/application/services/auth.service.ts | 36 +- .../consume-user-payment.use-case.ts | 188 ++++ .../use-cases/get-my-circle.use-case.ts | 37 + .../get-my-user-referral-info.use-case.ts | 49 ++ .../register-user-referral.use-case.ts | 86 ++ .../entities/user-point-balance.entity.ts | 7 + .../entities/user-point-transaction.entity.ts | 19 + .../entities/user-referral-code.entity.ts | 6 + .../user-referral-relationship.entity.ts | 13 + .../repositories/user-point.repository.ts | 147 ++++ .../user-referral-code.repository.ts | 72 ++ .../user-referral-relationship.repository.ts | 116 +++ .../controllers/referral-admin.controller.ts | 55 +- .../referral-internal.controller.ts | 31 +- .../rest/controllers/referral.controller.ts | 79 +- .../referral-service/src/referral.module.ts | 22 +- .../migrations/011-user-referral-points.sql | 77 ++ 24 files changed, 2205 insertions(+), 243 deletions(-) create mode 100644 packages/services/referral-service/src/application/use-cases/consume-user-payment.use-case.ts create mode 100644 packages/services/referral-service/src/application/use-cases/get-my-circle.use-case.ts create mode 100644 packages/services/referral-service/src/application/use-cases/get-my-user-referral-info.use-case.ts create mode 100644 packages/services/referral-service/src/application/use-cases/register-user-referral.use-case.ts create mode 100644 packages/services/referral-service/src/domain/entities/user-point-balance.entity.ts create mode 100644 packages/services/referral-service/src/domain/entities/user-point-transaction.entity.ts create mode 100644 packages/services/referral-service/src/domain/entities/user-referral-code.entity.ts create mode 100644 packages/services/referral-service/src/domain/entities/user-referral-relationship.entity.ts create mode 100644 packages/services/referral-service/src/infrastructure/repositories/user-point.repository.ts create mode 100644 packages/services/referral-service/src/infrastructure/repositories/user-referral-code.repository.ts create mode 100644 packages/services/referral-service/src/infrastructure/repositories/user-referral-relationship.repository.ts create mode 100644 packages/shared/database/migrations/011-user-referral-points.sql diff --git a/it0-web-admin/src/app/(admin)/referral/page.tsx b/it0-web-admin/src/app/(admin)/referral/page.tsx index 8bb8771..b97a7fa 100644 --- a/it0-web-admin/src/app/(admin)/referral/page.tsx +++ b/it0-web-admin/src/app/(admin)/referral/page.tsx @@ -6,10 +6,18 @@ import { getAdminReferralStats, listAdminRelationships, listAdminRewards, + listAdminUserCircles, + listAdminUserPoints, + listAdminUserBalances, } from '@/infrastructure/repositories/api-referral.repository'; -import { ReferralStatus, RewardStatus } from '@/domain/entities/referral'; +import { + ReferralStatus, + RewardStatus, + UserReferralStatus, + PointTransactionType, +} from '@/domain/entities/referral'; -// ── Status badge helper ──────────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────────────────────── function StatusBadge({ status }: { status: string }) { const colorMap: Record = { @@ -30,8 +38,18 @@ function StatusBadge({ status }: { status: string }) { ); } -function formatCents(cents: number) { - return `$${(cents / 100).toFixed(2)}`; +function PointTypeBadge({ type }: { type: PointTransactionType }) { + const labelMap: Record = { + REFERRAL_FIRST_PAYMENT: '推荐首次订阅', + REFERRAL_RECURRING: '推荐续订', + REFERRAL_L2: '二级推荐', + REFERRAL_WELCOME: '加入欢迎礼', + REDEMPTION_QUOTA: '兑换配额', + REDEMPTION_UNLOCK: '兑换解锁', + ADMIN_GRANT: '平台赠送', + EXPIRY: '积分过期', + }; + return {labelMap[type] ?? type}; } function formatDate(iso: string | null) { @@ -39,7 +57,29 @@ function formatDate(iso: string | null) { return new Date(iso).toLocaleDateString('zh-CN'); } -// ── Stats Overview ───────────────────────────────────────────────────────────── +function Pagination({ + page, total, limit, onPageChange, +}: { page: number; total: number; limit: number; onPageChange: (p: number) => void }) { + if (total <= limit) return null; + const pages = Math.ceil(total / limit); + return ( +
+ + {page + 1} / {pages} + +
+ ); +} + +// ── Stats Overview ──────────────────────────────────────────────────────────── function StatsOverview() { const { data, isLoading } = useQuery({ @@ -52,13 +92,15 @@ function StatsOverview() { if (!data) return null; const cards = [ - { label: '总推荐数', value: data.totalReferrals, color: 'text-indigo-600' }, + { label: '企业推荐总数', value: data.totalReferrals, color: 'text-indigo-600' }, { label: '已激活推荐', value: data.activeReferrals, color: 'text-green-600' }, - { label: '待领积分记录', value: data.pendingRewards, color: 'text-amber-600' }, + { label: '待结算奖励', value: data.pendingRewards, color: 'text-amber-600' }, + { label: '用户圈子关系', value: data.totalUserCircles ?? 0, color: 'text-purple-600' }, + { label: '积分交易笔数', value: data.totalPointTransactions ?? 0, color: 'text-blue-600' }, ]; return ( -
+
{cards.map((c) => (

{c.label}

@@ -69,7 +111,7 @@ function StatsOverview() { ); } -// ── Relationships Table ──────────────────────────────────────────────────────── +// ── Tenant Relationships Table ──────────────────────────────────────────────── function RelationshipsTable() { const [status, setStatus] = useState(''); @@ -78,22 +120,17 @@ function RelationshipsTable() { const { data, isLoading } = useQuery({ queryKey: ['referral-relationships', status, page], - queryFn: () => - listAdminRelationships({ - status: (status as ReferralStatus) || undefined, - limit, - offset: page * limit, - }), + queryFn: () => listAdminRelationships({ status: (status as ReferralStatus) || undefined, limit, offset: page * limit }), retry: 1, }); return (
-

推荐关系

+

企业推荐关系

{ setStatus(e.target.value as any); setPage(0); }} + onChange={(e) => { setStatus(e.target.value as RewardStatus); setPage(0); }} className="text-sm border rounded-lg px-3 py-1.5 bg-background" > @@ -213,7 +225,7 @@ function RewardsTable() { {(data?.items ?? []).map((r) => ( {r.beneficiaryTenantId.slice(0, 12)}… - {formatCents(r.amountCents)} + ${(r.amountCents / 100).toFixed(2)} {triggerLabel(r.triggerType, r.recurringMonth)} {r.sourceInvoiceId?.slice(0, 8) ?? '—'}… @@ -222,54 +234,218 @@ function RewardsTable() { ))} {data?.items.length === 0 && ( - - 暂无数据 - + 暂无数据 )}
)} + +
+ ); +} - {data && data.total > limit && ( -
- - - {page + 1} / {Math.ceil(data.total / limit)} - - +// ── User Circles Table ──────────────────────────────────────────────────────── + +function UserCirclesTable() { + const [status, setStatus] = useState(''); + const [page, setPage] = useState(0); + const limit = 20; + + const { data, isLoading } = useQuery({ + queryKey: ['referral-user-circles', status, page], + queryFn: () => listAdminUserCircles({ status: (status as UserReferralStatus) || undefined, limit, offset: page * limit }), + retry: 1, + }); + + return ( +
+
+

用户个人圈子关系

+ +
+ + {isLoading ? ( +
加载中…
+ ) : ( +
+ + + + {['邀请人', '被邀请人', '邀请码', '层级', '状态', '加入时间', '激活时间'].map((h) => ( + + ))} + + + + {(data?.items ?? []).map((r) => ( + + + + + + + + + + ))} + {data?.items.length === 0 && ( + + )} + +
{h}
{r.referrerUserId.slice(0, 12)}…{r.referredUserId.slice(0, 12)}…{r.referralCode} + + L{r.level} + + {formatDate(r.createdAt)}{formatDate(r.activatedAt)}
暂无数据
)} + +
+ ); +} + +// ── User Points Table ───────────────────────────────────────────────────────── + +function UserPointsTable() { + const [view, setView] = useState<'transactions' | 'balances'>('transactions'); + const [page, setPage] = useState(0); + const limit = 20; + + const txQuery = useQuery({ + queryKey: ['referral-user-points', page], + queryFn: () => listAdminUserPoints({ limit, offset: page * limit }), + enabled: view === 'transactions', + retry: 1, + }); + + const balQuery = useQuery({ + queryKey: ['referral-user-balances', page], + queryFn: () => listAdminUserBalances({ limit, offset: page * limit }), + enabled: view === 'balances', + retry: 1, + }); + + const isLoading = view === 'transactions' ? txQuery.isLoading : balQuery.isLoading; + + return ( +
+
+

用户积分管理

+
+ + +
+
+ + {isLoading ? ( +
加载中…
+ ) : view === 'transactions' ? ( + <> +
+ + + + {['用户ID', '积分变动', '类型', '备注', '时间'].map((h) => ( + + ))} + + + + {(txQuery.data?.items ?? []).map((r) => ( + + + + + + + + ))} + {txQuery.data?.items.length === 0 && ( + + )} + +
{h}
{r.userId.slice(0, 12)}… 0 ? 'text-green-600' : 'text-red-500'}`}> + {r.delta > 0 ? '+' : ''}{r.delta} pts + {r.note ?? '—'}{formatDate(r.createdAt)}
暂无数据
+
+ + + ) : ( + <> +
+ + + + {['用户ID', '当前余额', '累计获得', '累计消耗', '更新时间'].map((h) => ( + + ))} + + + + {(balQuery.data?.items ?? []).map((r, i) => ( + + + + + + + + ))} + {balQuery.data?.items.length === 0 && ( + + )} + +
{h}
+ {i === 0 && 🥇} + {i === 1 && 🥈} + {i === 2 && 🥉} + {r.userId.slice(0, 12)}… + {r.balance} pts+{r.totalEarned} pts{r.totalSpent} pts{formatDate(r.updatedAt)}
暂无数据
+
+ + + )}
); } // ── Page ────────────────────────────────────────────────────────────────────── -type Tab = 'overview' | 'relationships' | 'rewards'; +type Tab = 'overview' | 'relationships' | 'rewards' | 'circles' | 'points'; export default function ReferralPage() { const [tab, setTab] = useState('overview'); const tabs: { key: Tab; label: string }[] = [ { key: 'overview', label: '概览' }, - { key: 'relationships', label: '推荐关系' }, - { key: 'rewards', label: '积分奖励' }, + { key: 'relationships', label: '企业推荐' }, + { key: 'rewards', label: '企业奖励' }, + { key: 'circles', label: '用户圈子' }, + { key: 'points', label: '用户积分' }, ]; return (

推荐管理

-

管理用户推荐关系与积分奖励

+

管理企业推荐关系、用户个人圈子与积分体系

{/* Tabs */} @@ -293,6 +469,8 @@ export default function ReferralPage() { {tab === 'overview' && } {tab === 'relationships' && } {tab === 'rewards' && } + {tab === 'circles' && } + {tab === 'points' && }
); } diff --git a/it0-web-admin/src/domain/entities/referral.ts b/it0-web-admin/src/domain/entities/referral.ts index 5876f8f..dc59024 100644 --- a/it0-web-admin/src/domain/entities/referral.ts +++ b/it0-web-admin/src/domain/entities/referral.ts @@ -39,3 +39,52 @@ export interface PaginatedResult { items: T[]; total: number; } + +// ── User-level / personal circle (C2C) ──────────────────────────────────────── + +export type UserReferralStatus = 'PENDING' | 'ACTIVE' | 'REWARDED' | 'EXPIRED'; + +export interface UserCircleRelationship { + id: string; + referrerUserId: string; + referredUserId: string; + referralCode: string; + level: number; + status: UserReferralStatus; + activatedAt: string | null; + rewardedAt: string | null; + createdAt: string; +} + +export type PointTransactionType = + | 'REFERRAL_FIRST_PAYMENT' + | 'REFERRAL_RECURRING' + | 'REFERRAL_L2' + | 'REFERRAL_WELCOME' + | 'REDEMPTION_QUOTA' + | 'REDEMPTION_UNLOCK' + | 'ADMIN_GRANT' + | 'EXPIRY'; + +export interface UserPointTransaction { + id: string; + userId: string; + delta: number; + type: PointTransactionType; + refId: string | null; + note: string | null; + createdAt: string; +} + +export interface UserPointBalance { + userId: string; + balance: number; + totalEarned: number; + totalSpent: number; + updatedAt: string; +} + +export interface ReferralAdminStatsExtended extends ReferralAdminStats { + totalUserCircles: number; + totalPointTransactions: number; +} diff --git a/it0-web-admin/src/infrastructure/repositories/api-referral.repository.ts b/it0-web-admin/src/infrastructure/repositories/api-referral.repository.ts index 78e7508..c5b4203 100644 --- a/it0-web-admin/src/infrastructure/repositories/api-referral.repository.ts +++ b/it0-web-admin/src/infrastructure/repositories/api-referral.repository.ts @@ -2,9 +2,14 @@ import { ReferralRelationship, ReferralReward, ReferralAdminStats, + ReferralAdminStatsExtended, PaginatedResult, ReferralStatus, RewardStatus, + UserReferralStatus, + UserCircleRelationship, + UserPointTransaction, + UserPointBalance, } from '@/domain/entities/referral'; function getAuthHeaders(): HeadersInit { @@ -19,7 +24,7 @@ function getAuthHeaders(): HeadersInit { const BASE = '/api/proxy'; -export async function getAdminReferralStats(): Promise { +export async function getAdminReferralStats(): Promise { const res = await fetch(`${BASE}/api/v1/referral/admin/stats`, { headers: getAuthHeaders(), }); @@ -58,3 +63,47 @@ export async function listAdminRewards(params: { if (!res.ok) throw new Error(`Failed to fetch rewards: ${res.status}`); return res.json(); } + +export async function listAdminUserCircles(params: { + status?: UserReferralStatus; + limit?: number; + offset?: number; +}): Promise> { + 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}/api/v1/referral/admin/user-circles?${q}`, { + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error(`Failed to fetch user circles: ${res.status}`); + return res.json(); +} + +export async function listAdminUserPoints(params: { + limit?: number; + offset?: number; +}): Promise> { + const q = new URLSearchParams(); + 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/referral/admin/user-points?${q}`, { + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error(`Failed to fetch user points: ${res.status}`); + return res.json(); +} + +export async function listAdminUserBalances(params: { + limit?: number; + offset?: number; +}): Promise> { + const q = new URLSearchParams(); + 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/referral/admin/user-balances?${q}`, { + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error(`Failed to fetch user balances: ${res.status}`); + return res.json(); +} diff --git a/it0_app/lib/features/referral/data/referral_repository.dart b/it0_app/lib/features/referral/data/referral_repository.dart index 9ace15f..f976728 100644 --- a/it0_app/lib/features/referral/data/referral_repository.dart +++ b/it0_app/lib/features/referral/data/referral_repository.dart @@ -47,6 +47,49 @@ class ReferralRepository { .toList(); return (items: items, total: data['total'] as int? ?? 0); } + + // ── User-level / personal circle ───────────────────────────────────────── + + Future getMyUserReferralInfo() async { + final res = await _dio.get('/api/v1/referral/me/user'); + return UserReferralInfo.fromJson(res.data as Map); + } + + Future<({List items, int total})> getMyCircle({ + int limit = 20, + int offset = 0, + }) async { + final res = await _dio.get( + '/api/v1/referral/me/circle', + queryParameters: {'limit': limit, 'offset': offset}, + ); + final data = res.data as Map; + final items = (data['items'] as List) + .map((e) => CircleMember.fromJson(e as Map)) + .toList(); + return (items: items, total: data['total'] as int? ?? 0); + } + + Future<({int balance, int totalEarned, int totalSpent, List transactions, int total})> + getMyPoints({int limit = 20, int offset = 0}) async { + final res = await _dio.get( + '/api/v1/referral/me/points', + queryParameters: {'limit': limit, 'offset': offset}, + ); + final data = res.data as Map; + final bal = data['balance'] as Map? ?? {}; + final txData = data['transactions'] as Map? ?? {}; + final txList = (txData['items'] as List? ?? []) + .map((e) => PointTransaction.fromJson(e as Map)) + .toList(); + return ( + balance: bal['balance'] as int? ?? 0, + totalEarned: bal['totalEarned'] as int? ?? 0, + totalSpent: (bal['totalSpent'] as int? ?? 0).abs(), + transactions: txList, + total: txData['total'] as int? ?? 0, + ); + } } // ── Riverpod provider ──────────────────────────────────────────────────────── diff --git a/it0_app/lib/features/referral/domain/models/referral_info.dart b/it0_app/lib/features/referral/domain/models/referral_info.dart index 68d57aa..f9e9a1b 100644 --- a/it0_app/lib/features/referral/domain/models/referral_info.dart +++ b/it0_app/lib/features/referral/domain/models/referral_info.dart @@ -68,6 +68,109 @@ class ReferralItem { bool get isActive => status == 'ACTIVE' || status == 'REWARDED'; } +// ── User-level personal circle models ───────────────────────────────────── + +class UserReferralInfo { + final String code; + final String shareUrl; + final int circleSize; + final int activeCount; + final int pointsBalance; + final int totalEarned; + final int totalSpent; + + const UserReferralInfo({ + required this.code, + required this.shareUrl, + required this.circleSize, + required this.activeCount, + required this.pointsBalance, + required this.totalEarned, + required this.totalSpent, + }); + + factory UserReferralInfo.fromJson(Map json) => UserReferralInfo( + code: json['code'] as String? ?? '', + shareUrl: json['shareUrl'] as String? ?? '', + circleSize: json['circleSize'] as int? ?? 0, + activeCount: json['activeCount'] as int? ?? 0, + pointsBalance: json['pointsBalance'] as int? ?? 0, + totalEarned: json['totalEarned'] as int? ?? 0, + totalSpent: json['totalSpent'] as int? ?? 0, + ); +} + +class CircleMember { + final String relationshipId; + final String referredUserId; + final int level; + final String status; + final DateTime joinedAt; + final DateTime? activatedAt; + + const CircleMember({ + required this.relationshipId, + required this.referredUserId, + required this.level, + required this.status, + required this.joinedAt, + this.activatedAt, + }); + + factory CircleMember.fromJson(Map json) => CircleMember( + relationshipId: json['relationshipId'] as String, + referredUserId: json['referredUserId'] as String, + level: json['level'] as int? ?? 1, + status: json['status'] as String? ?? 'PENDING', + joinedAt: DateTime.parse(json['joinedAt'] as String), + activatedAt: json['activatedAt'] != null + ? DateTime.parse(json['activatedAt'] as String) + : null, + ); + + bool get isActive => status == 'ACTIVE' || status == 'REWARDED'; +} + +class PointTransaction { + final String id; + final int delta; + final String type; + final String? note; + final DateTime createdAt; + + const PointTransaction({ + required this.id, + required this.delta, + required this.type, + this.note, + required this.createdAt, + }); + + factory PointTransaction.fromJson(Map json) => PointTransaction( + id: json['id'] as String, + delta: json['delta'] as int? ?? 0, + type: json['type'] as String? ?? '', + note: json['note'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + + bool get isEarned => delta > 0; + + String get typeLabel { + switch (type) { + case 'REFERRAL_FIRST_PAYMENT': return '推荐首次订阅奖励'; + case 'REFERRAL_RECURRING': return '推荐续订奖励'; + case 'REFERRAL_L2': return '二级推荐奖励'; + case 'REFERRAL_WELCOME': return '加入欢迎积分'; + case 'REDEMPTION_QUOTA': return '兑换使用配额'; + case 'REDEMPTION_UNLOCK': return '兑换智能体解锁'; + case 'ADMIN_GRANT': return '平台赠送'; + case 'EXPIRY': return '积分过期'; + default: return type; + } + } +} + class RewardItem { final String id; final int amountCents; diff --git a/it0_app/lib/features/referral/presentation/providers/referral_providers.dart b/it0_app/lib/features/referral/presentation/providers/referral_providers.dart index b4a4d6d..e6c07da 100644 --- a/it0_app/lib/features/referral/presentation/providers/referral_providers.dart +++ b/it0_app/lib/features/referral/presentation/providers/referral_providers.dart @@ -2,25 +2,50 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../data/referral_repository.dart'; import '../../domain/models/referral_info.dart'; -/// My referral info + code +/// My tenant referral info + code final referralInfoProvider = FutureProvider((ref) async { return ref.watch(referralRepositoryProvider).getMyReferralInfo(); }); -/// My direct referrals (first page) +/// My direct tenant referrals (first page) final referralListProvider = FutureProvider<({List items, int total})>((ref) async { return ref.watch(referralRepositoryProvider).getMyReferrals(); }); -/// Pending rewards +/// Pending tenant rewards final pendingRewardsProvider = FutureProvider<({List items, int total})>((ref) async { return ref.watch(referralRepositoryProvider).getMyRewards(status: 'PENDING'); }); -/// All rewards (for history tab) +/// All tenant rewards (for history tab) final allRewardsProvider = FutureProvider<({List items, int total})>((ref) async { return ref.watch(referralRepositoryProvider).getMyRewards(); }); + +// ── User-level / personal circle ───────────────────────────────────────────── + +/// Personal user referral info (code, shareUrl, circleSize, pointsBalance …) +final userReferralInfoProvider = FutureProvider((ref) async { + return ref.watch(referralRepositoryProvider).getMyUserReferralInfo(); +}); + +/// Personal circle members (level-1 invitees) +final myCircleProvider = + FutureProvider<({List items, int total})>((ref) async { + return ref.watch(referralRepositoryProvider).getMyCircle(); +}); + +/// Points balance + recent transactions +final myPointsProvider = FutureProvider< + ({ + int balance, + int totalEarned, + int totalSpent, + List transactions, + int total + })>((ref) async { + return ref.watch(referralRepositoryProvider).getMyPoints(); +}); diff --git a/it0_app/lib/features/referral/presentation/screens/referral_screen.dart b/it0_app/lib/features/referral/presentation/screens/referral_screen.dart index b16e985..51d65d6 100644 --- a/it0_app/lib/features/referral/presentation/screens/referral_screen.dart +++ b/it0_app/lib/features/referral/presentation/screens/referral_screen.dart @@ -12,112 +12,629 @@ class ReferralScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); - final infoAsync = ref.watch(referralInfoProvider); final isDark = Theme.of(context).brightness == Brightness.dark; final cardColor = isDark ? AppColors.surface : Colors.white; - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( + return DefaultTabController( + length: 2, + child: Scaffold( backgroundColor: AppColors.background, - title: Text(l10n.referralScreenTitle), - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () { - ref.invalidate(referralInfoProvider); - ref.invalidate(referralListProvider); - ref.invalidate(pendingRewardsProvider); - }, + appBar: AppBar( + backgroundColor: AppColors.background, + title: Text(l10n.referralScreenTitle), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + ref.invalidate(referralInfoProvider); + ref.invalidate(referralListProvider); + ref.invalidate(pendingRewardsProvider); + ref.invalidate(userReferralInfoProvider); + ref.invalidate(myCircleProvider); + ref.invalidate(myPointsProvider); + }, + ), + ], + bottom: TabBar( + indicatorColor: AppColors.primary, + labelColor: AppColors.primary, + unselectedLabelColor: Colors.grey, + tabs: const [ + Tab(text: '企业推荐'), + Tab(text: '我的圈子'), + ], ), - ], - ), - body: infoAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('加载失败: $e')), - data: (info) => _ReferralBody(info: info, cardColor: cardColor), + ), + body: TabBarView( + children: [ + _TenantReferralTab(cardColor: cardColor), + _PersonalCircleTab(cardColor: cardColor), + ], + ), ), ); } } -class _ReferralBody extends ConsumerWidget { - final ReferralInfo info; - final Color cardColor; +// ══════════════════════════════════════════════════════════════════════════════ +// Tab 1 — Tenant / B2B referral (existing) +// ══════════════════════════════════════════════════════════════════════════════ - const _ReferralBody({required this.info, required this.cardColor}); +class _TenantReferralTab extends ConsumerWidget { + final Color cardColor; + const _TenantReferralTab({required this.cardColor}); @override Widget build(BuildContext context, WidgetRef ref) { - final l10n = AppLocalizations.of(context); - return ListView( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - children: [ - // ── Referral Code Card ───────────────────────────────────── - _ReferralCodeCard(info: info, cardColor: cardColor), - const SizedBox(height: 16), - - // ── Stats Row ───────────────────────────────────────────── - _StatsRow(info: info, cardColor: cardColor), - const SizedBox(height: 20), - - // ── Reward Rules ────────────────────────────────────────── - _RewardRulesCard(cardColor: cardColor), - const SizedBox(height: 20), - - // ── Referral List ───────────────────────────────────────── - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - l10n.referralRecordsSection, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), - ), - TextButton( - onPressed: () => _showReferralList(context), - child: Text(l10n.viewAllReferralsLink), - ), - ], - ), - _ReferralPreviewList(cardColor: cardColor), - const SizedBox(height: 20), - - // ── Reward List ─────────────────────────────────────────── - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - l10n.pendingRewardsSection, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), - ), - TextButton( - onPressed: () => _showRewardList(context), - child: Text(l10n.viewAllRewardsLink), - ), - ], - ), - _RewardPreviewList(cardColor: cardColor), - const SizedBox(height: 40), - ], - ); - } - - void _showReferralList(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const _ReferralListPage()), - ); - } - - void _showRewardList(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const _RewardListPage()), + final infoAsync = ref.watch(referralInfoProvider); + return infoAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('加载失败: $e')), + data: (info) => ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + _ReferralCodeCard(info: info, cardColor: cardColor), + const SizedBox(height: 16), + _StatsRow(info: info, cardColor: cardColor), + const SizedBox(height: 20), + _RewardRulesCard(cardColor: cardColor), + const SizedBox(height: 20), + _SectionHeader( + title: '推荐记录', + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const _ReferralListPage())), + ), + _ReferralPreviewList(cardColor: cardColor), + const SizedBox(height: 20), + _SectionHeader( + title: '待结算奖励', + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const _RewardListPage())), + ), + _RewardPreviewList(cardColor: cardColor), + const SizedBox(height: 40), + ], + ), ); } } -// ── Referral Code Card ──────────────────────────────────────────────────────── +// ══════════════════════════════════════════════════════════════════════════════ +// Tab 2 — Personal circle / C2C +// ══════════════════════════════════════════════════════════════════════════════ + +class _PersonalCircleTab extends ConsumerWidget { + final Color cardColor; + const _PersonalCircleTab({required this.cardColor}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final infoAsync = ref.watch(userReferralInfoProvider); + return infoAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('加载失败: $e')), + data: (info) => ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + _UserCodeCard(info: info, cardColor: cardColor), + const SizedBox(height: 16), + _PointsBalanceCard(info: info, cardColor: cardColor), + const SizedBox(height: 20), + _CircleRulesCard(cardColor: cardColor), + const SizedBox(height: 20), + _SectionHeader( + title: '我的圈子成员', + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const _CircleListPage())), + ), + _CirclePreviewList(cardColor: cardColor), + const SizedBox(height: 20), + _SectionHeader( + title: '积分记录', + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const _PointsHistoryPage())), + ), + _PointsPreviewList(cardColor: cardColor), + const SizedBox(height: 40), + ], + ), + ); + } +} + +// ── User Referral Code Card ─────────────────────────────────────────────────── + +class _UserCodeCard extends StatelessWidget { + final UserReferralInfo info; + final Color cardColor; + + const _UserCodeCard({required this.info, required this.cardColor}); + + @override + Widget build(BuildContext context) { + return Card( + color: cardColor, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '我的个人邀请码', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + info.code, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + letterSpacing: 2, + color: Color(0xFF7C3AED), + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy, color: Color(0xFF7C3AED)), + tooltip: '复制邀请码', + onPressed: () => _copy(context, info.code), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.link, size: 18), + label: const Text('复制邀请链接'), + onPressed: () => _copy(context, info.shareUrl), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF7C3AED), + side: const BorderSide(color: Color(0xFF7C3AED)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + icon: const Icon(Icons.share, size: 18), + label: const Text('分享'), + onPressed: () => _share(context, info), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF7C3AED), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + void _copy(BuildContext context, String text) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已复制到剪贴板'), + duration: Duration(seconds: 2)), + ); + } + + void _share(BuildContext context, UserReferralInfo info) { + final text = + '邀请你加入 IT0 智能体管理平台,用AI管理你的数字工作!加入即获 200 积分奖励!\n邀请码:${info.code}\n链接:${info.shareUrl}'; + _copy(context, text); + } +} + +// ── Points Balance Card ─────────────────────────────────────────────────────── + +class _PointsBalanceCard extends StatelessWidget { + final UserReferralInfo info; + final Color cardColor; + + const _PointsBalanceCard({required this.info, required this.cardColor}); + + @override + Widget build(BuildContext context) { + return Card( + color: cardColor, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.stars_rounded, color: Color(0xFFF59E0B), size: 20), + SizedBox(width: 6), + Text('积分余额', + style: + TextStyle(fontWeight: FontWeight.w600, fontSize: 15)), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + _PointsStatItem( + label: '当前余额', + value: '${info.pointsBalance}', + unit: 'pts', + color: const Color(0xFF7C3AED), + ), + const SizedBox(width: 12), + _PointsStatItem( + label: '圈子成员', + value: '${info.circleSize}', + unit: '人', + color: const Color(0xFF10B981), + ), + const SizedBox(width: 12), + _PointsStatItem( + label: '累计获得', + value: '${info.totalEarned}', + unit: 'pts', + color: const Color(0xFF6366F1), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _PointsStatItem extends StatelessWidget { + final String label; + final String value; + final String unit; + final Color color; + + const _PointsStatItem({ + required this.label, + required this.value, + required this.unit, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 4), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: color, + ), + ), + TextSpan( + text: ' $unit', + style: TextStyle(fontSize: 12, color: color.withAlpha(180)), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Circle Rules Card ───────────────────────────────────────────────────────── + +class _CircleRulesCard extends StatelessWidget { + final Color cardColor; + const _CircleRulesCard({required this.cardColor}); + + @override + Widget build(BuildContext context) { + return Card( + color: cardColor, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.people_alt_rounded, + color: Color(0xFF7C3AED), size: 20), + SizedBox(width: 6), + Text('圈子奖励规则', + style: + TextStyle(fontWeight: FontWeight.w600, fontSize: 15)), + ], + ), + const SizedBox(height: 12), + _RuleItem( + icon: Icons.card_giftcard_rounded, + color: const Color(0xFF7C3AED), + text: '新成员加入你的圈子,你和对方各获 200 积分欢迎礼', + ), + const SizedBox(height: 8), + _RuleItem( + icon: Icons.star_rounded, + color: const Color(0xFF6366F1), + text: '圈子成员订阅 Pro 时,你获 1500 pts,对方获 500 pts', + ), + const SizedBox(height: 8), + _RuleItem( + icon: Icons.star_rounded, + color: const Color(0xFF7C3AED), + text: '圈子成员订阅 Enterprise 时,你获 5000 pts,对方获 2000 pts', + ), + const SizedBox(height: 8), + _RuleItem( + icon: Icons.repeat_rounded, + color: const Color(0xFF10B981), + text: '每月续订时你持续获得付款额 10% 的积分,最长 12 个月', + ), + const SizedBox(height: 8), + _RuleItem( + icon: Icons.account_tree_rounded, + color: const Color(0xFFF59E0B), + text: '二级圈子续订,你获 5% 积分,最长 6 个月', + ), + const SizedBox(height: 8), + _RuleItem( + icon: Icons.redeem_rounded, + color: const Color(0xFF10B981), + text: '积分可兑换额外使用配额或解锁智能体', + ), + ], + ), + ), + ); + } +} + +// ── Circle Member Preview ───────────────────────────────────────────────────── + +class _CirclePreviewList extends ConsumerWidget { + final Color cardColor; + const _CirclePreviewList({required this.cardColor}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(myCircleProvider); + return async.when( + loading: () => const SizedBox( + height: 60, child: Center(child: CircularProgressIndicator())), + error: (_, __) => const SizedBox.shrink(), + data: (result) { + if (result.items.isEmpty) { + return _EmptyCard(cardColor: cardColor, message: '暂无圈子成员,快去邀请好友吧!'); + } + final preview = result.items.take(3).toList(); + return Card( + color: cardColor, + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + clipBehavior: Clip.antiAlias, + child: Column( + children: preview.map((m) => _CircleMemberTile(member: m)).toList(), + ), + ); + }, + ); + } +} + +class _CircleMemberTile extends StatelessWidget { + final CircleMember member; + const _CircleMemberTile({required this.member}); + + @override + Widget build(BuildContext context) { + final statusColor = member.isActive + ? const Color(0xFF10B981) + : member.status == 'EXPIRED' + ? Colors.grey + : const Color(0xFFF59E0B); + final statusLabel = switch (member.status) { + 'PENDING' => '待激活', + 'ACTIVE' => '已激活', + 'REWARDED' => '已奖励', + 'EXPIRED' => '已过期', + _ => member.status, + }; + final levelLabel = member.level == 1 ? 'L1' : 'L2'; + + return ListTile( + leading: CircleAvatar( + backgroundColor: const Color(0xFF7C3AED).withAlpha(20), + child: Text( + levelLabel, + style: const TextStyle( + color: Color(0xFF7C3AED), + fontWeight: FontWeight.bold, + fontSize: 12), + ), + ), + title: Text( + member.referredUserId.length > 8 + ? '${member.referredUserId.substring(0, 8)}...' + : member.referredUserId, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + subtitle: Text( + '加入于 ${_formatDate(member.joinedAt)}', + style: const TextStyle(fontSize: 12), + ), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withAlpha(20), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + statusLabel, + style: TextStyle( + fontSize: 12, + color: statusColor, + fontWeight: FontWeight.w500), + ), + ), + ); + } + + String _formatDate(DateTime dt) => + '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; +} + +// ── Points Preview List ─────────────────────────────────────────────────────── + +class _PointsPreviewList extends ConsumerWidget { + final Color cardColor; + const _PointsPreviewList({required this.cardColor}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(myPointsProvider); + return async.when( + loading: () => const SizedBox( + height: 60, child: Center(child: CircularProgressIndicator())), + error: (_, __) => const SizedBox.shrink(), + data: (result) { + if (result.transactions.isEmpty) { + return _EmptyCard(cardColor: cardColor, message: '暂无积分记录'); + } + final preview = result.transactions.take(3).toList(); + return Card( + color: cardColor, + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + clipBehavior: Clip.antiAlias, + child: Column( + children: + preview.map((t) => _PointsTile(tx: t)).toList(), + ), + ); + }, + ); + } +} + +class _PointsTile extends StatelessWidget { + final PointTransaction tx; + const _PointsTile({required this.tx}); + + @override + Widget build(BuildContext context) { + final color = + tx.isEarned ? const Color(0xFF10B981) : const Color(0xFFEF4444); + final sign = tx.isEarned ? '+' : ''; + + return ListTile( + leading: CircleAvatar( + backgroundColor: color.withAlpha(20), + child: Icon( + tx.isEarned ? Icons.add_circle_outline : Icons.remove_circle_outline, + color: color, + size: 20, + ), + ), + title: Text(tx.typeLabel, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + subtitle: Text( + _formatDate(tx.createdAt), + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + trailing: Text( + '$sign${tx.delta} pts', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: color), + ), + ); + } + + String _formatDate(DateTime dt) => + '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; +} + +// ── Full list pages (circle & points) ──────────────────────────────────────── + +class _CircleListPage extends ConsumerWidget { + const _CircleListPage(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(myCircleProvider); + return Scaffold( + appBar: AppBar(title: const Text('我的圈子')), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('加载失败: $e')), + data: (result) => result.items.isEmpty + ? const Center(child: Text('暂无圈子成员')) + : ListView.separated( + itemCount: result.items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => + _CircleMemberTile(member: result.items[i]), + ), + ), + ); + } +} + +class _PointsHistoryPage extends ConsumerWidget { + const _PointsHistoryPage(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(myPointsProvider); + return Scaffold( + appBar: AppBar(title: const Text('积分记录')), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('加载失败: $e')), + data: (result) => result.transactions.isEmpty + ? const Center(child: Text('暂无积分记录')) + : ListView.separated( + itemCount: result.transactions.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => + _PointsTile(tx: result.transactions[i]), + ), + ), + ); + } +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Shared / Tenant tab widgets (unchanged logic, extracted) +// ══════════════════════════════════════════════════════════════════════════════ class _ReferralCodeCard extends StatelessWidget { final ReferralInfo info; @@ -203,20 +720,19 @@ class _ReferralCodeCard extends StatelessWidget { final l10n = AppLocalizations.of(context); Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.copiedToClipboard), duration: const Duration(seconds: 2)), + SnackBar( + content: Text(l10n.copiedToClipboard), + duration: const Duration(seconds: 2)), ); } void _share(BuildContext context, ReferralInfo info) { - // For a full implementation, use share_plus package - // For now, copy the share text - final text = '邀请你使用 IT0 智能运维平台,注册即可获得积分奖励!\n推荐码:${info.referralCode}\n链接:${info.shareUrl}'; + final text = + '邀请你使用 IT0 智能体管理平台,注册即可获得积分奖励!\n推荐码:${info.referralCode}\n链接:${info.shareUrl}'; _copy(context, text); } } -// ── Stats Row ───────────────────────────────────────────────────────────────── - class _StatsRow extends StatelessWidget { final ReferralInfo info; final Color cardColor; @@ -300,8 +816,8 @@ class _StatCard extends StatelessWidget { if (unit.isNotEmpty) TextSpan( text: unit, - style: TextStyle( - fontSize: 13, color: color.withAlpha(180)), + style: + TextStyle(fontSize: 13, color: color.withAlpha(180)), ), ], ), @@ -314,11 +830,8 @@ class _StatCard extends StatelessWidget { } } -// ── Reward Rules Card ───────────────────────────────────────────────────────── - class _RewardRulesCard extends StatelessWidget { final Color cardColor; - const _RewardRulesCard({required this.cardColor}); @override @@ -335,11 +848,13 @@ class _RewardRulesCard extends StatelessWidget { children: [ Row( children: [ - const Icon(Icons.card_giftcard, color: Color(0xFFF59E0B), size: 20), + const Icon(Icons.card_giftcard, + color: Color(0xFFF59E0B), size: 20), const SizedBox(width: 6), Text( l10n.rewardRulesTitle, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15), + style: const TextStyle( + fontWeight: FontWeight.w600, fontSize: 15), ), ], ), @@ -396,7 +911,49 @@ class _RuleItem extends StatelessWidget { } } -// ── Preview Lists ───────────────────────────────────────────────────────────── +class _SectionHeader extends StatelessWidget { + final String title; + final VoidCallback onTap; + + const _SectionHeader({required this.title, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, + style: const TextStyle( + fontWeight: FontWeight.w600, fontSize: 16)), + TextButton(onPressed: onTap, child: const Text('查看全部')), + ], + ); + } +} + +class _EmptyCard extends StatelessWidget { + final Color cardColor; + final String message; + + const _EmptyCard({required this.cardColor, required this.message}); + + @override + Widget build(BuildContext context) { + return Card( + color: cardColor, + elevation: 0, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Center( + child: Text(message, + style: const TextStyle(color: Colors.grey, fontSize: 13)), + ), + ), + ); + } +} class _ReferralPreviewList extends ConsumerWidget { final Color cardColor; @@ -408,27 +965,12 @@ class _ReferralPreviewList extends ConsumerWidget { final async = ref.watch(referralListProvider); return async.when( loading: () => const SizedBox( - height: 60, - child: Center(child: CircularProgressIndicator()), - ), + height: 60, child: Center(child: CircularProgressIndicator())), error: (_, __) => const SizedBox.shrink(), data: (result) { if (result.items.isEmpty) { - return Card( - color: cardColor, - elevation: 0, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Padding( - padding: const EdgeInsets.all(20), - child: Center( - child: Text( - l10n.noReferralsMessage, - style: const TextStyle(color: Colors.grey, fontSize: 13), - ), - ), - ), - ); + return _EmptyCard( + cardColor: cardColor, message: l10n.noReferralsMessage); } final preview = result.items.take(3).toList(); return Card( @@ -494,7 +1036,9 @@ class _ReferralTile extends StatelessWidget { child: Text( statusLabel, style: TextStyle( - fontSize: 12, color: statusColor, fontWeight: FontWeight.w500), + fontSize: 12, + color: statusColor, + fontWeight: FontWeight.w500), ), ), ); @@ -518,21 +1062,8 @@ class _RewardPreviewList extends ConsumerWidget { error: (_, __) => const SizedBox.shrink(), data: (result) { if (result.items.isEmpty) { - return Card( - color: cardColor, - elevation: 0, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Padding( - padding: const EdgeInsets.all(20), - child: Center( - child: Text( - l10n.noPendingRewardsMessage, - style: const TextStyle(color: Colors.grey, fontSize: 13), - ), - ), - ), - ); + return _EmptyCard( + cardColor: cardColor, message: l10n.noPendingRewardsMessage); } final preview = result.items.take(3).toList(); return Card( @@ -542,9 +1073,7 @@ class _RewardPreviewList extends ConsumerWidget { RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), clipBehavior: Clip.antiAlias, child: Column( - children: preview - .map((item) => _RewardTile(item: item)) - .toList(), + children: preview.map((item) => _RewardTile(item: item)).toList(), ), ); }, @@ -592,8 +1121,6 @@ class _RewardTile extends StatelessWidget { } } -// ── Full list pages ─────────────────────────────────────────────────────────── - class _ReferralListPage extends ConsumerWidget { const _ReferralListPage(); diff --git a/packages/services/auth-service/src/application/services/auth.service.ts b/packages/services/auth-service/src/application/services/auth.service.ts index 72212be..e695d63 100644 --- a/packages/services/auth-service/src/application/services/auth.service.ts +++ b/packages/services/auth-service/src/application/services/auth.service.ts @@ -188,6 +188,8 @@ export class AuthService { // Async: register with referral-service (fire-and-forget) this.registerReferral(user.tenantId, user.id).catch(() => {}); + // Also create user-level personal referral code (C2C circle) + this.registerUserReferral(user.id).catch(() => {}); const tokens = this.generateTokens(user); return { @@ -298,8 +300,9 @@ export class AuthService { user.isActive = true; // Async: register tenant with referral-service (fire-and-forget) - // referralCode is not supported at tenant-creation time yet (future: pass via register body) this.registerReferral(slug, userId).catch(() => {}); + // Also create user-level personal referral code (C2C circle) + this.registerUserReferral(userId).catch(() => {}); const tokens = this.generateTokens(user); return { @@ -315,6 +318,37 @@ export class AuthService { }; } + /** + * Fire-and-forget call to referral-service to create the user's personal referral code + * and optionally link a personal-circle referrer (C2C layer). + * userReferralCode must start with "USR-" to distinguish from tenant codes. + */ + private async registerUserReferral(userId: string, userReferralCode?: string): Promise { + const referralServiceUrl = this.configService.get( + 'REFERRAL_SERVICE_URL', + 'http://referral-service:3012', + ); + const internalKey = this.configService.get('INTERNAL_API_KEY', 'changeme-internal-key'); + const url = `${referralServiceUrl}/api/v1/referral/internal/user-register`; + const http = await import('http'); + const https = await import('https'); + const body = JSON.stringify({ userId, userReferralCode }); + return new Promise((resolve) => { + const lib = url.startsWith('https') ? https : http; + const req = lib.request(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'X-Internal-Api-Key': internalKey, + }, + }, (res) => { res.resume(); resolve(); }); + req.on('error', () => resolve()); + req.write(body); + req.end(); + }); + } + /** * Fire-and-forget call to referral-service to create the tenant's referral code. * Uses HTTP to the internal referral-service URL (not through Kong). diff --git a/packages/services/referral-service/src/application/use-cases/consume-user-payment.use-case.ts b/packages/services/referral-service/src/application/use-cases/consume-user-payment.use-case.ts new file mode 100644 index 0000000..493737f --- /dev/null +++ b/packages/services/referral-service/src/application/use-cases/consume-user-payment.use-case.ts @@ -0,0 +1,188 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { UserReferralRelationshipRepository } from '../../infrastructure/repositories/user-referral-relationship.repository'; +import { UserPointRepository } from '../../infrastructure/repositories/user-point.repository'; +import { ProcessedEventRepository } from '../../infrastructure/repositories/processed-event.repository'; + +/** + * Listens to events:payment.received (consumer group: referral-user-service). + * When a user pays, finds their user-level referral relationship and awards points + * to their referrer (and level-2 referrer). + * + * Point award rules: + * First payment (Pro): referrer +1500 pts, referred +500 pts + * First payment (Enterprise): referrer +5000 pts, referred +2000 pts + * Recurring (≤12 months): referrer + 10% of payment_cents/10 pts + * Level-2 (≤6 months): l2-referrer + 5% of payment_cents/10 pts + * + * (Convention: 1 USD = 100 pts, so payment_cents / 100 * pts_per_dollar) + */ + +interface PaymentPayload { + tenantId: string; + userId?: string; // present in new events; may be absent in legacy + invoiceId: string; + paymentId: string; + amount: number; // USD cents + currency: string; + planName?: string; +} + +const FIRST_PAYMENT_POINTS: Record = { + pro: { referrer: 1500, referred: 500 }, + enterprise: { referrer: 5000, referred: 2000 }, + free: { referrer: 0, referred: 0 }, +}; +const RECURRING_PCT = 0.10; // 10% of amount_cents → pts (1 USD = 100 pts) +const RECURRING_MAX = 12; +const L2_PCT = 0.05; // 5% +const L2_MAX = 6; + +const STREAM = 'events:payment.received'; +const GROUP = 'referral-user-service'; // separate group from tenant referral consumer +const CONSUMER = `referral-user-${process.pid}`; + +@Injectable() +export class ConsumeUserPaymentUseCase implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ConsumeUserPaymentUseCase.name); + private client: Redis; + private running = false; + + constructor( + private readonly relRepo: UserReferralRelationshipRepository, + private readonly pointRepo: UserPointRepository, + private readonly processedRepo: ProcessedEventRepository, + private readonly config: ConfigService, + ) {} + + async onModuleInit() { + const url = this.config.get('REDIS_URL', 'redis://localhost:6379'); + this.client = new Redis(url); + try { + await this.client.xgroup('CREATE', STREAM, GROUP, '0', 'MKSTREAM'); + } catch { /* group exists */ } + this.running = true; + this.loop().catch((e) => this.logger.error(`User payment loop error: ${e.message}`)); + } + + async onModuleDestroy() { + this.running = false; + this.client.disconnect(); + } + + private async loop() { + while (this.running) { + try { + const res = await this.client.xreadgroup( + 'GROUP', GROUP, CONSUMER, + 'COUNT', '10', 'BLOCK', '5000', + 'STREAMS', STREAM, '>', + ) as Array<[string, Array<[string, string[]]>]> | null; + + if (!res) continue; + + for (const [, messages] of res) { + for (const [id, fields] of messages) { + const record: Record = {}; + for (let i = 0; i < fields.length; i += 2) record[fields[i]] = fields[i + 1]; + await this.process(id, record); + await this.client.xack(STREAM, GROUP, id); + } + } + } catch (e: any) { + if (this.running) { + this.logger.error(`Redis error: ${e.message}`); + await new Promise((r) => setTimeout(r, 5000)); + } + } + } + } + + private async process(msgId: string, record: Record) { + try { + const payload: PaymentPayload = JSON.parse(record['data'] ?? '{}'); + const userId = payload.userId; + if (!userId) return; // legacy event without userId — skip user-level processing + + const eventId = `user-payment:${payload.paymentId ?? msgId}`; + if (await this.processedRepo.hasProcessed(eventId)) return; + + // Find direct (level-1) referral relationship for this user + const level1 = await this.relRepo.findByReferredUserId(userId); + if (!level1) { + await this.processedRepo.markProcessed(eventId, 'payment.received'); + return; + } + + const isFirst = level1.status === 'PENDING'; + const plan = (payload.planName ?? 'pro').toLowerCase(); + + if (isFirst) { + await this.relRepo.updateStatus(level1.id, 'ACTIVE', { activatedAt: new Date() }); + + const rules = FIRST_PAYMENT_POINTS[plan] ?? FIRST_PAYMENT_POINTS['pro']; + + if (rules.referrer > 0) { + await this.pointRepo.addTransaction( + level1.referrerUserId, + rules.referrer, + 'REFERRAL_FIRST_PAYMENT', + level1.id, + `First payment by referred user (${plan})`, + ); + } + if (rules.referred > 0) { + await this.pointRepo.addTransaction( + userId, + rules.referred, + 'REFERRAL_FIRST_PAYMENT', + level1.id, + `Bonus for subscribing via referral (${plan})`, + ); + } + + await this.relRepo.updateStatus(level1.id, 'REWARDED', { rewardedAt: new Date() }); + this.logger.log(`User referral first-payment: referrer=${level1.referrerUserId} +${rules.referrer} pts`); + + } else if (level1.status === 'ACTIVE' || level1.status === 'REWARDED') { + const recurringCount = await this.relRepo.countRecurringRewards(level1.id); + if (recurringCount < RECURRING_MAX) { + const pts = Math.floor(payload.amount * RECURRING_PCT / 100); // cents→pts (1USD=100pts) + if (pts > 0) { + await this.pointRepo.addTransaction( + level1.referrerUserId, + pts, + 'REFERRAL_RECURRING', + level1.id, + `Recurring reward month ${recurringCount + 1}`, + ); + } + } + + // Level-2 indirect reward + const level2Rows = await this.relRepo.findByReferrerUserId(level1.referrerUserId); + const l2 = level2Rows.items.find((r) => r.referredUserId === userId && r.level === 2); + if (l2) { + const l2Count = await this.relRepo.countRecurringRewards(l2.id); + if (l2Count < L2_MAX) { + const l2Pts = Math.floor(payload.amount * L2_PCT / 100); + if (l2Pts > 0) { + await this.pointRepo.addTransaction( + l2.referrerUserId, + l2Pts, + 'REFERRAL_L2', + l2.id, + `Level-2 recurring reward`, + ); + } + } + } + } + + await this.processedRepo.markProcessed(eventId, 'payment.received'); + } catch (e: any) { + this.logger.error(`Failed to process user payment ${msgId}: ${e.message}`); + } + } +} diff --git a/packages/services/referral-service/src/application/use-cases/get-my-circle.use-case.ts b/packages/services/referral-service/src/application/use-cases/get-my-circle.use-case.ts new file mode 100644 index 0000000..0c039f6 --- /dev/null +++ b/packages/services/referral-service/src/application/use-cases/get-my-circle.use-case.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { UserReferralRelationshipRepository } from '../../infrastructure/repositories/user-referral-relationship.repository'; +import { UserReferralRelationship } from '../../domain/entities/user-referral-relationship.entity'; + +export interface CircleMember { + relationshipId: string; + referredUserId: string; + level: 1 | 2; + status: string; + joinedAt: Date; + activatedAt: Date | null; +} + +export interface GetMyCircleResult { + items: CircleMember[]; + total: number; +} + +@Injectable() +export class GetMyCircleUseCase { + constructor(private readonly relRepo: UserReferralRelationshipRepository) {} + + async execute(userId: string, limit = 20, offset = 0): Promise { + const { items, total } = await this.relRepo.findByReferrerUserId(userId, limit, offset); + return { + items: items.map((r: UserReferralRelationship) => ({ + relationshipId: r.id, + referredUserId: r.referredUserId, + level: r.level, + status: r.status, + joinedAt: r.createdAt, + activatedAt: r.activatedAt, + })), + total, + }; + } +} diff --git a/packages/services/referral-service/src/application/use-cases/get-my-user-referral-info.use-case.ts b/packages/services/referral-service/src/application/use-cases/get-my-user-referral-info.use-case.ts new file mode 100644 index 0000000..afd388a --- /dev/null +++ b/packages/services/referral-service/src/application/use-cases/get-my-user-referral-info.use-case.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { UserReferralCodeRepository } from '../../infrastructure/repositories/user-referral-code.repository'; +import { UserReferralRelationshipRepository } from '../../infrastructure/repositories/user-referral-relationship.repository'; +import { UserPointRepository } from '../../infrastructure/repositories/user-point.repository'; + +export interface UserReferralInfo { + code: string; + shareUrl: string; + circleSize: number; // total direct invitees (level-1) + activeCount: number; // those who have paid + pointsBalance: number; + totalEarned: number; + totalSpent: number; +} + +@Injectable() +export class GetMyUserReferralInfoUseCase { + constructor( + private readonly codeRepo: UserReferralCodeRepository, + private readonly relRepo: UserReferralRelationshipRepository, + private readonly pointRepo: UserPointRepository, + private readonly config: ConfigService, + ) {} + + async execute(userId: string): Promise { + // Ensure code exists (create if first visit) + const codeEntity = await this.codeRepo.findByUserId(userId) + ?? await this.codeRepo.createForUser(userId); + + const appUrl = this.config.get('APP_URL', 'https://it0.szaiai.com'); + const shareUrl = `${appUrl}/join?ref=${codeEntity.code}`; + + const { items, total } = await this.relRepo.findByReferrerUserId(userId, 1000, 0); + const activeCount = items.filter((r) => r.status !== 'PENDING' && r.status !== 'EXPIRED').length; + + const balance = await this.pointRepo.getBalance(userId); + + return { + code: codeEntity.code, + shareUrl, + circleSize: total, + activeCount, + pointsBalance: balance.balance, + totalEarned: balance.totalEarned, + totalSpent: Math.abs(balance.totalSpent), + }; + } +} diff --git a/packages/services/referral-service/src/application/use-cases/register-user-referral.use-case.ts b/packages/services/referral-service/src/application/use-cases/register-user-referral.use-case.ts new file mode 100644 index 0000000..c72c9c2 --- /dev/null +++ b/packages/services/referral-service/src/application/use-cases/register-user-referral.use-case.ts @@ -0,0 +1,86 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { UserReferralCodeRepository } from '../../infrastructure/repositories/user-referral-code.repository'; +import { UserReferralRelationshipRepository } from '../../infrastructure/repositories/user-referral-relationship.repository'; +import { UserPointRepository } from '../../infrastructure/repositories/user-point.repository'; + +export interface RegisterUserReferralInput { + userId: string; + /** Optional: personal referral code entered by the user at registration */ + userReferralCode?: string; +} + +export interface RegisterUserReferralResult { + myCode: string; +} + +// Welcome bonus points awarded to a newly invited user +const WELCOME_BONUS_POINTS = 200; + +@Injectable() +export class RegisterUserReferralUseCase { + private readonly logger = new Logger(RegisterUserReferralUseCase.name); + + constructor( + private readonly codeRepo: UserReferralCodeRepository, + private readonly relRepo: UserReferralRelationshipRepository, + private readonly pointRepo: UserPointRepository, + ) {} + + async execute(input: RegisterUserReferralInput): Promise { + const { userId, userReferralCode } = input; + + // 1. Auto-create this user's own referral code + const myCode = await this.codeRepo.createForUser(userId); + + // 2. If a referral code was provided, establish the relationship + if (userReferralCode) { + const codeEntity = await this.codeRepo.findByCode(userReferralCode); + if (!codeEntity) { + this.logger.warn(`User referral code not found: ${userReferralCode} for user ${userId}`); + return { myCode: myCode.code }; + } + + // Prevent self-referral + if (codeEntity.userId === userId) { + this.logger.warn(`Self-referral attempt by user ${userId}`); + return { myCode: myCode.code }; + } + + // Prevent double-referral (idempotent) + const existing = await this.relRepo.findByReferredUserId(userId); + if (existing) { + this.logger.warn(`User ${userId} already has a referral relationship`); + return { myCode: myCode.code }; + } + + // Create level-1 (direct) relationship + const rel = await this.relRepo.create(codeEntity.userId, userId, userReferralCode, 1); + + // Award welcome bonus to the newly referred user + await this.pointRepo.addTransaction( + userId, + WELCOME_BONUS_POINTS, + 'REFERRAL_WELCOME', + rel.id, + `Welcome bonus — invited by user ${codeEntity.userId}`, + ); + + // Check if the referrer was themselves referred (level-2 chain) + const referrerRel = await this.relRepo.findByReferredUserId(codeEntity.userId); + if (referrerRel) { + await this.relRepo.create( + referrerRel.referrerUserId, + userId, + userReferralCode, + 2, + ); + } + + this.logger.log( + `User referral created: ${codeEntity.userId} → ${userId} (code: ${userReferralCode})`, + ); + } + + return { myCode: myCode.code }; + } +} diff --git a/packages/services/referral-service/src/domain/entities/user-point-balance.entity.ts b/packages/services/referral-service/src/domain/entities/user-point-balance.entity.ts new file mode 100644 index 0000000..470856b --- /dev/null +++ b/packages/services/referral-service/src/domain/entities/user-point-balance.entity.ts @@ -0,0 +1,7 @@ +export class UserPointBalance { + userId: string; + balance: number; + totalEarned: number; + totalSpent: number; // always ≤ 0 + updatedAt: Date; +} diff --git a/packages/services/referral-service/src/domain/entities/user-point-transaction.entity.ts b/packages/services/referral-service/src/domain/entities/user-point-transaction.entity.ts new file mode 100644 index 0000000..99b9c49 --- /dev/null +++ b/packages/services/referral-service/src/domain/entities/user-point-transaction.entity.ts @@ -0,0 +1,19 @@ +export type PointTransactionType = + | 'REFERRAL_FIRST_PAYMENT' // first subscription payment by referred user + | 'REFERRAL_RECURRING' // recurring monthly reward (≤12 months) + | 'REFERRAL_L2' // level-2 indirect reward (≤6 months) + | 'REFERRAL_WELCOME' // welcome bonus to newly referred user + | 'REDEMPTION_QUOTA' // redeemed for extra usage quota + | 'REDEMPTION_UNLOCK' // redeemed to unlock a premium agent + | 'ADMIN_GRANT' // platform admin manual adjustment + | 'EXPIRY'; // points expiry deduction + +export class UserPointTransaction { + id: string; + userId: string; + delta: number; // positive = earned, negative = spent + type: PointTransactionType; + refId: string | null; // user_referral_relationships.id or redemption id + note: string | null; + createdAt: Date; +} diff --git a/packages/services/referral-service/src/domain/entities/user-referral-code.entity.ts b/packages/services/referral-service/src/domain/entities/user-referral-code.entity.ts new file mode 100644 index 0000000..492442c --- /dev/null +++ b/packages/services/referral-service/src/domain/entities/user-referral-code.entity.ts @@ -0,0 +1,6 @@ +export class UserReferralCode { + userId: string; + code: string; + clickCount: number; + createdAt: Date; +} diff --git a/packages/services/referral-service/src/domain/entities/user-referral-relationship.entity.ts b/packages/services/referral-service/src/domain/entities/user-referral-relationship.entity.ts new file mode 100644 index 0000000..e8c1dfc --- /dev/null +++ b/packages/services/referral-service/src/domain/entities/user-referral-relationship.entity.ts @@ -0,0 +1,13 @@ +export type UserReferralStatus = 'PENDING' | 'ACTIVE' | 'REWARDED' | 'EXPIRED'; + +export class UserReferralRelationship { + id: string; + referrerUserId: string; + referredUserId: string; + referralCode: string; + level: 1 | 2; + status: UserReferralStatus; + activatedAt: Date | null; + rewardedAt: Date | null; + createdAt: Date; +} diff --git a/packages/services/referral-service/src/infrastructure/repositories/user-point.repository.ts b/packages/services/referral-service/src/infrastructure/repositories/user-point.repository.ts new file mode 100644 index 0000000..9dc6a07 --- /dev/null +++ b/packages/services/referral-service/src/infrastructure/repositories/user-point.repository.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { UserPointBalance } from '../../domain/entities/user-point-balance.entity'; +import { UserPointTransaction, PointTransactionType } from '../../domain/entities/user-point-transaction.entity'; + +@Injectable() +export class UserPointRepository { + constructor(@InjectDataSource() private readonly ds: DataSource) {} + + /** + * Add a point transaction AND atomically update the balance cache. + * delta > 0 = earn, delta < 0 = spend. + * Throws if resulting balance would go below 0. + */ + async addTransaction( + userId: string, + delta: number, + type: PointTransactionType, + refId?: string, + note?: string, + ): Promise { + return this.ds.transaction(async (em) => { + // Upsert balance row (lock for update) + await em.query( + `INSERT INTO public.user_point_balances (user_id) VALUES ($1) + ON CONFLICT (user_id) DO NOTHING`, + [userId], + ); + const bal = await em.query( + `SELECT balance FROM public.user_point_balances WHERE user_id = $1 FOR UPDATE`, + [userId], + ); + const current: number = bal[0]?.balance ?? 0; + if (delta < 0 && current + delta < 0) { + throw new Error(`Insufficient points: balance=${current}, requested=${-delta}`); + } + + // Insert transaction + const txRows = await em.query( + `INSERT INTO public.user_point_transactions (user_id, delta, type, ref_id, note) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [userId, delta, type, refId ?? null, note ?? null], + ); + + // Update balance + const earnedDelta = delta > 0 ? delta : 0; + const spentDelta = delta < 0 ? delta : 0; + await em.query( + `UPDATE public.user_point_balances + SET balance = balance + $2, + total_earned = total_earned + $3, + total_spent = total_spent + $4, + updated_at = NOW() + WHERE user_id = $1`, + [userId, delta, earnedDelta, spentDelta], + ); + + return this.mapTx(txRows[0]); + }); + } + + async getBalance(userId: string): Promise { + const rows = await this.ds.query( + `SELECT * FROM public.user_point_balances WHERE user_id = $1`, + [userId], + ); + if (rows[0]) return this.mapBal(rows[0]); + // No row yet — return zeroes + return { userId, balance: 0, totalEarned: 0, totalSpent: 0, updatedAt: new Date() }; + } + + async getTransactions( + userId: string, + limit = 20, + offset = 0, + ): Promise<{ items: UserPointTransaction[]; total: number }> { + const [rows, count] = await Promise.all([ + this.ds.query( + `SELECT * FROM public.user_point_transactions + WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, + [userId, limit, offset], + ), + this.ds.query( + `SELECT COUNT(*)::int AS total FROM public.user_point_transactions WHERE user_id = $1`, + [userId], + ), + ]); + return { items: rows.map((r: any) => this.mapTx(r)), total: count[0]?.total ?? 0 }; + } + + /** Admin: list all point transactions across all users (paginated). */ + async getAllTransactions( + limit = 50, + offset = 0, + ): Promise<{ items: UserPointTransaction[]; total: number }> { + const [rows, count] = await Promise.all([ + this.ds.query( + `SELECT * FROM public.user_point_transactions ORDER BY created_at DESC LIMIT $1 OFFSET $2`, + [limit, offset], + ), + this.ds.query( + `SELECT COUNT(*)::int AS total FROM public.user_point_transactions`, + ), + ]); + return { items: rows.map((r: any) => this.mapTx(r)), total: count[0]?.total ?? 0 }; + } + + /** Admin: get all user balance summaries (paginated). */ + async getAllBalances( + limit = 50, + offset = 0, + ): Promise<{ items: UserPointBalance[]; total: number }> { + const [rows, count] = await Promise.all([ + this.ds.query( + `SELECT * FROM public.user_point_balances ORDER BY balance DESC LIMIT $1 OFFSET $2`, + [limit, offset], + ), + this.ds.query( + `SELECT COUNT(*)::int AS total FROM public.user_point_balances`, + ), + ]); + return { items: rows.map((r: any) => this.mapBal(r)), total: count[0]?.total ?? 0 }; + } + + private mapTx(r: any): UserPointTransaction { + return { + id: r.id, + userId: r.user_id, + delta: r.delta, + type: r.type, + refId: r.ref_id, + note: r.note, + createdAt: r.created_at, + }; + } + + private mapBal(r: any): UserPointBalance { + return { + userId: r.user_id, + balance: r.balance, + totalEarned: r.total_earned, + totalSpent: r.total_spent, + updatedAt: r.updated_at, + }; + } +} diff --git a/packages/services/referral-service/src/infrastructure/repositories/user-referral-code.repository.ts b/packages/services/referral-service/src/infrastructure/repositories/user-referral-code.repository.ts new file mode 100644 index 0000000..48ca7a5 --- /dev/null +++ b/packages/services/referral-service/src/infrastructure/repositories/user-referral-code.repository.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { UserReferralCode } from '../../domain/entities/user-referral-code.entity'; + +/** Generates a random user referral code: USR-XXXX-XXXX */ +function generateUserCode(): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + const seg = (n: number) => + Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); + return `USR-${seg(4)}-${seg(4)}`; +} + +@Injectable() +export class UserReferralCodeRepository { + constructor(@InjectDataSource() private readonly ds: DataSource) {} + + /** Create a referral code for a user (idempotent — skips if already exists). */ + async createForUser(userId: string): Promise { + // Try up to 5 times to find a unique code + for (let attempt = 0; attempt < 5; attempt++) { + const code = generateUserCode(); + try { + const rows = await this.ds.query( + `INSERT INTO public.user_referral_codes (user_id, code) + VALUES ($1, $2) + ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id + RETURNING *`, + [userId, code], + ); + return this.map(rows[0]); + } catch { + // code collision — retry + } + } + // Fallback: just return whatever exists + return this.findByUserId(userId) as Promise; + } + + async findByCode(code: string): Promise { + const rows = await this.ds.query( + `SELECT * FROM public.user_referral_codes WHERE code = $1`, + [code], + ); + return rows[0] ? this.map(rows[0]) : null; + } + + async findByUserId(userId: string): Promise { + const rows = await this.ds.query( + `SELECT * FROM public.user_referral_codes WHERE user_id = $1`, + [userId], + ); + return rows[0] ? this.map(rows[0]) : null; + } + + /** Increment click count asynchronously (fire-and-forget safe). */ + async incrementClickCount(code: string): Promise { + await this.ds.query( + `UPDATE public.user_referral_codes SET click_count = click_count + 1 WHERE code = $1`, + [code], + ); + } + + private map(r: any): UserReferralCode { + return { + userId: r.user_id, + code: r.code, + clickCount: r.click_count, + createdAt: r.created_at, + }; + } +} diff --git a/packages/services/referral-service/src/infrastructure/repositories/user-referral-relationship.repository.ts b/packages/services/referral-service/src/infrastructure/repositories/user-referral-relationship.repository.ts new file mode 100644 index 0000000..185e540 --- /dev/null +++ b/packages/services/referral-service/src/infrastructure/repositories/user-referral-relationship.repository.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { UserReferralRelationship, UserReferralStatus } from '../../domain/entities/user-referral-relationship.entity'; + +@Injectable() +export class UserReferralRelationshipRepository { + constructor(@InjectDataSource() private readonly ds: DataSource) {} + + async create( + referrerUserId: string, + referredUserId: string, + referralCode: string, + level: 1 | 2, + ): Promise { + const rows = await this.ds.query( + `INSERT INTO public.user_referral_relationships + (referrer_user_id, referred_user_id, referral_code, level) + VALUES ($1, $2, $3, $4) + ON CONFLICT (referred_user_id) DO NOTHING + RETURNING *`, + [referrerUserId, referredUserId, referralCode, level], + ); + return rows[0] ? this.map(rows[0]) : this.findByReferredUserId(referredUserId) as Promise; + } + + async findByReferredUserId(userId: string): Promise { + const rows = await this.ds.query( + `SELECT * FROM public.user_referral_relationships WHERE referred_user_id = $1`, + [userId], + ); + return rows[0] ? this.map(rows[0]) : null; + } + + /** Direct circle members (level-1 referrals made by this user). */ + async findByReferrerUserId( + userId: string, + limit = 20, + offset = 0, + ): Promise<{ items: UserReferralRelationship[]; total: number }> { + const [rows, count] = await Promise.all([ + this.ds.query( + `SELECT * FROM public.user_referral_relationships + WHERE referrer_user_id = $1 AND level = 1 + ORDER BY created_at DESC LIMIT $2 OFFSET $3`, + [userId, limit, offset], + ), + this.ds.query( + `SELECT COUNT(*)::int AS total FROM public.user_referral_relationships + WHERE referrer_user_id = $1 AND level = 1`, + [userId], + ), + ]); + return { items: rows.map((r: any) => this.map(r)), total: count[0]?.total ?? 0 }; + } + + async updateStatus( + id: string, + status: UserReferralStatus, + timestamps: { activatedAt?: Date; rewardedAt?: Date } = {}, + ): Promise { + await this.ds.query( + `UPDATE public.user_referral_relationships + SET status = $2, + activated_at = COALESCE($3, activated_at), + rewarded_at = COALESCE($4, rewarded_at) + WHERE id = $1`, + [id, status, timestamps.activatedAt ?? null, timestamps.rewardedAt ?? null], + ); + } + + /** Admin: list all user-level circle relationships (paginated). */ + async findAll( + status?: UserReferralStatus, + limit = 50, + offset = 0, + ): Promise<{ items: UserReferralRelationship[]; total: number }> { + const where = status ? `WHERE status = $3` : ''; + const params = status ? [limit, offset, status] : [limit, offset]; + const [rows, count] = await Promise.all([ + this.ds.query( + `SELECT * FROM public.user_referral_relationships ${where} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, + params, + ), + this.ds.query( + `SELECT COUNT(*)::int AS total FROM public.user_referral_relationships ${where}`, + status ? [status] : [], + ), + ]); + return { items: rows.map((r: any) => this.map(r)), total: count[0]?.total ?? 0 }; + } + + /** Count recurring-type point transactions already issued for a relationship. */ + async countRecurringRewards(relationshipId: string): Promise { + const rows = await this.ds.query( + `SELECT COUNT(*)::int AS cnt FROM public.user_point_transactions + WHERE ref_id = $1 AND type IN ('REFERRAL_RECURRING', 'REFERRAL_L2')`, + [relationshipId], + ); + return rows[0]?.cnt ?? 0; + } + + private map(r: any): UserReferralRelationship { + return { + id: r.id, + referrerUserId: r.referrer_user_id, + referredUserId: r.referred_user_id, + referralCode: r.referral_code, + level: r.level, + status: r.status, + activatedAt: r.activated_at, + rewardedAt: r.rewarded_at, + createdAt: r.created_at, + }; + } +} diff --git a/packages/services/referral-service/src/interfaces/rest/controllers/referral-admin.controller.ts b/packages/services/referral-service/src/interfaces/rest/controllers/referral-admin.controller.ts index ed0fb53..c97fdef 100644 --- a/packages/services/referral-service/src/interfaces/rest/controllers/referral-admin.controller.ts +++ b/packages/services/referral-service/src/interfaces/rest/controllers/referral-admin.controller.ts @@ -10,8 +10,11 @@ import { import { GetReferralListUseCase } from '../../../application/use-cases/get-referral-list.use-case'; import { ReferralRelationshipRepository } from '../../../infrastructure/repositories/referral-relationship.repository'; import { ReferralRewardRepository } from '../../../infrastructure/repositories/referral-reward.repository'; +import { UserReferralRelationshipRepository } from '../../../infrastructure/repositories/user-referral-relationship.repository'; +import { UserPointRepository } from '../../../infrastructure/repositories/user-point.repository'; import { ReferralStatus } from '../../../domain/entities/referral-relationship.entity'; import { RewardStatus } from '../../../domain/entities/referral-reward.entity'; +import { UserReferralStatus } from '../../../domain/entities/user-referral-relationship.entity'; import * as jwt from 'jsonwebtoken'; /** @@ -24,9 +27,13 @@ export class ReferralAdminController { private readonly getReferralList: GetReferralListUseCase, private readonly relationshipRepo: ReferralRelationshipRepository, private readonly rewardRepo: ReferralRewardRepository, + private readonly userRelRepo: UserReferralRelationshipRepository, + private readonly userPointRepo: UserPointRepository, ) {} - /** GET /api/v1/referral/admin/relationships — all referral relationships */ + // ── Tenant-level (B2B) ─────────────────────────────────────────────────── + + /** GET /api/v1/referral/admin/relationships — all tenant referral relationships */ @Get('relationships') async listRelationships( @Headers('authorization') auth: string, @@ -38,7 +45,7 @@ export class ReferralAdminController { return this.relationshipRepo.findAll(status, Math.min(limit, 200), offset); } - /** GET /api/v1/referral/admin/rewards — all reward records */ + /** GET /api/v1/referral/admin/rewards — all tenant reward records */ @Get('rewards') async listRewards( @Headers('authorization') auth: string, @@ -54,18 +61,60 @@ export class ReferralAdminController { @Get('stats') async getStats(@Headers('authorization') auth: string) { this.requireAdmin(auth); - const [totalRel, activeRel, pendingRewards] = await Promise.all([ + const [totalRel, activeRel, pendingRewards, totalCircles, totalPointTxns] = await Promise.all([ this.relationshipRepo.findAll(undefined, 1, 0).then((r) => r.total), this.relationshipRepo.findAll('ACTIVE', 1, 0).then((r) => r.total), this.rewardRepo.findAll('PENDING', 1, 0).then((r) => r.total), + this.userRelRepo.findAll(undefined, 1, 0).then((r) => r.total), + this.userPointRepo.getAllTransactions(1, 0).then((r) => r.total), ]); return { totalReferrals: totalRel, activeReferrals: activeRel, pendingRewards, + totalUserCircles: totalCircles, + totalPointTransactions: totalPointTxns, }; } + // ── User-level / personal circle (C2C) ────────────────────────────────── + + /** GET /api/v1/referral/admin/user-circles — all user-level circle relationships */ + @Get('user-circles') + async listUserCircles( + @Headers('authorization') auth: string, + @Query('status') status: UserReferralStatus | undefined, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + this.requireAdmin(auth); + return this.userRelRepo.findAll(status, Math.min(limit, 200), offset); + } + + /** GET /api/v1/referral/admin/user-points — all point transactions across users */ + @Get('user-points') + async listUserPoints( + @Headers('authorization') auth: string, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + this.requireAdmin(auth); + return this.userPointRepo.getAllTransactions(Math.min(limit, 200), offset); + } + + /** GET /api/v1/referral/admin/user-balances — all user point balance summaries */ + @Get('user-balances') + async listUserBalances( + @Headers('authorization') auth: string, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + this.requireAdmin(auth); + return this.userPointRepo.getAllBalances(Math.min(limit, 200), offset); + } + + // ── Helper ─────────────────────────────────────────────────────────────── + private requireAdmin(auth: string) { if (!auth?.startsWith('Bearer ')) { throw new UnauthorizedException('Missing authorization header'); diff --git a/packages/services/referral-service/src/interfaces/rest/controllers/referral-internal.controller.ts b/packages/services/referral-service/src/interfaces/rest/controllers/referral-internal.controller.ts index 68af11c..31c6ca2 100644 --- a/packages/services/referral-service/src/interfaces/rest/controllers/referral-internal.controller.ts +++ b/packages/services/referral-service/src/interfaces/rest/controllers/referral-internal.controller.ts @@ -11,6 +11,7 @@ import { import { InternalApiGuard } from '../guards/internal-api.guard'; import { RegisterWithCodeUseCase } from '../../../application/use-cases/register-with-code.use-case'; import { GetPendingCreditsUseCase } from '../../../application/use-cases/get-pending-credits.use-case'; +import { RegisterUserReferralUseCase } from '../../../application/use-cases/register-user-referral.use-case'; /** * Internal service-to-service endpoints. @@ -18,9 +19,12 @@ import { GetPendingCreditsUseCase } from '../../../application/use-cases/get-pen * NOT exposed through Kong to the outside world. * * Called by: - * - auth-service: on tenant registration → POST /api/v1/referral/internal/register - * - billing-service: before invoice → GET /api/v1/referral/internal/:tenantId/pending-credits - * - billing-service: after invoice → POST /api/v1/referral/internal/:tenantId/apply-credits + * - auth-service (on registration): + * POST /internal/register → tenant-level code + B2B relationship + * POST /internal/user-register → user-level code + personal circle relationship + * - billing-service: + * GET /internal/:tenantId/pending-credits → credits to apply to next invoice + * POST /internal/:tenantId/apply-credits → mark credits applied */ @Controller('api/v1/referral/internal') @UseGuards(InternalApiGuard) @@ -28,11 +32,12 @@ export class ReferralInternalController { constructor( private readonly registerWithCode: RegisterWithCodeUseCase, private readonly getPendingCredits: GetPendingCreditsUseCase, + private readonly registerUserReferral: RegisterUserReferralUseCase, ) {} /** - * Called by auth-service immediately after a new tenant is created. - * Creates the tenant's own referral code and optionally links a referrer. + * Called by auth-service after a new TENANT is created (B2B flow). + * Creates the tenant's own referral code; optionally links a tenant-level referrer. */ @Post('register') @HttpCode(HttpStatus.OK) @@ -46,6 +51,22 @@ export class ReferralInternalController { }); } + /** + * Called by auth-service after ANY user registration (B2B or personal). + * Creates the user's personal referral code; optionally links a personal-circle referrer. + * userReferralCode must start with "USR-" to distinguish from tenant codes. + */ + @Post('user-register') + @HttpCode(HttpStatus.OK) + async userRegister( + @Body() body: { userId: string; userReferralCode?: string }, + ) { + return this.registerUserReferral.execute({ + userId: body.userId, + userReferralCode: body.userReferralCode, + }); + } + /** * Called by billing-service before generating an invoice. * Returns total pending credit and individual reward IDs. diff --git a/packages/services/referral-service/src/interfaces/rest/controllers/referral.controller.ts b/packages/services/referral-service/src/interfaces/rest/controllers/referral.controller.ts index d32db47..a7fbdeb 100644 --- a/packages/services/referral-service/src/interfaces/rest/controllers/referral.controller.ts +++ b/packages/services/referral-service/src/interfaces/rest/controllers/referral.controller.ts @@ -11,11 +11,26 @@ import { import { GetMyReferralInfoUseCase } from '../../../application/use-cases/get-my-referral-info.use-case'; import { ValidateReferralCodeUseCase } from '../../../application/use-cases/validate-referral-code.use-case'; import { GetReferralListUseCase } from '../../../application/use-cases/get-referral-list.use-case'; +import { GetMyUserReferralInfoUseCase } from '../../../application/use-cases/get-my-user-referral-info.use-case'; +import { GetMyCircleUseCase } from '../../../application/use-cases/get-my-circle.use-case'; +import { UserPointRepository } from '../../../infrastructure/repositories/user-point.repository'; import * as jwt from 'jsonwebtoken'; /** - * User-facing referral endpoints. - * Kong enforces JWT — we extract tenant/user from the Authorization header here. + * User-facing referral endpoints (JWT required via Kong). + * + * Tenant-level (B2B): + * GET /me → tenant referral code + stats + * GET /me/referrals → tenant-level direct referral list + * GET /me/rewards → tenant credit rewards + * + * User-level / personal circle (C2C): + * GET /me/user → personal code + points balance + * GET /me/circle → personal circle members (level-1 invitees) + * GET /me/points → points balance + transaction history + * + * Public: + * GET /validate?code=... → validate any referral code */ @Controller('api/v1/referral') export class ReferralController { @@ -25,16 +40,19 @@ export class ReferralController { private readonly getMyReferralInfo: GetMyReferralInfoUseCase, private readonly validateCode: ValidateReferralCodeUseCase, private readonly getReferralList: GetReferralListUseCase, + private readonly getMyUserReferralInfo: GetMyUserReferralInfoUseCase, + private readonly getMyCircle: GetMyCircleUseCase, + private readonly pointRepo: UserPointRepository, ) {} - /** GET /api/v1/referral/me — get current tenant's referral info & code */ + // ── Tenant-level (B2B) ────────────────────────────────────────────────── + @Get('me') async getMe(@Headers('authorization') auth: string) { const { tenantId, userId } = this.extractJwt(auth); return this.getMyReferralInfo.execute(tenantId, userId); } - /** GET /api/v1/referral/me/referrals?limit=20&offset=0 — list direct referrals */ @Get('me/referrals') async getMyReferrals( @Headers('authorization') auth: string, @@ -45,7 +63,6 @@ export class ReferralController { return this.getReferralList.getReferrals(tenantId, Math.min(limit, 100), offset); } - /** GET /api/v1/referral/me/rewards?status=PENDING&limit=20&offset=0 */ @Get('me/rewards') async getMyRewards( @Headers('authorization') auth: string, @@ -57,24 +74,56 @@ export class ReferralController { return this.getReferralList.getRewards(tenantId, status, Math.min(limit, 100), offset); } - /** GET /api/v1/referral/validate/:code — public, validate a referral code */ + // ── User-level / personal circle (C2C) ───────────────────────────────── + + /** GET /me/user — personal code, shareUrl, circle size, points balance */ + @Get('me/user') + async getMyUserInfo(@Headers('authorization') auth: string) { + const { userId } = this.extractJwt(auth); + return this.getMyUserReferralInfo.execute(userId); + } + + /** GET /me/circle — personal circle members (level-1 invitees) */ + @Get('me/circle') + async getMyCircleMembers( + @Headers('authorization') auth: string, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + const { userId } = this.extractJwt(auth); + return this.getMyCircle.execute(userId, Math.min(limit, 100), offset); + } + + /** GET /me/points — points balance + recent transactions */ + @Get('me/points') + async getMyPoints( + @Headers('authorization') auth: string, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + ) { + const { userId } = this.extractJwt(auth); + const [balance, txns] = await Promise.all([ + this.pointRepo.getBalance(userId), + this.pointRepo.getTransactions(userId, Math.min(limit, 100), offset), + ]); + return { balance, transactions: txns }; + } + + // ── Public ─────────────────────────────────────────────────────────────── + @Get('validate') async validate(@Query('code') code: string) { return this.validateCode.execute(code ?? ''); } + // ── Helper ─────────────────────────────────────────────────────────────── + private extractJwt(auth: string): { tenantId: string; userId: string } { - if (!auth?.startsWith('Bearer ')) { - throw new UnauthorizedException('Missing authorization header'); - } + 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, - }; + const payload = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret') as any; + return { tenantId: payload.tenantId, userId: payload.sub }; } catch { throw new UnauthorizedException('Invalid JWT'); } diff --git a/packages/services/referral-service/src/referral.module.ts b/packages/services/referral-service/src/referral.module.ts index db9d46e..7206475 100644 --- a/packages/services/referral-service/src/referral.module.ts +++ b/packages/services/referral-service/src/referral.module.ts @@ -16,6 +16,10 @@ import { ReferralRelationshipRepository } from './infrastructure/repositories/re import { ReferralRewardRepository } from './infrastructure/repositories/referral-reward.repository'; import { ReferralStatRepository } from './infrastructure/repositories/referral-stat.repository'; import { ProcessedEventRepository } from './infrastructure/repositories/processed-event.repository'; +// User-level repositories +import { UserReferralCodeRepository } from './infrastructure/repositories/user-referral-code.repository'; +import { UserReferralRelationshipRepository } from './infrastructure/repositories/user-referral-relationship.repository'; +import { UserPointRepository } from './infrastructure/repositories/user-point.repository'; // Application Use Cases import { GetMyReferralInfoUseCase } from './application/use-cases/get-my-referral-info.use-case'; @@ -24,6 +28,11 @@ import { RegisterWithCodeUseCase } from './application/use-cases/register-with-c import { ConsumePaymentReceivedUseCase } from './application/use-cases/consume-payment-received.use-case'; import { GetReferralListUseCase } from './application/use-cases/get-referral-list.use-case'; import { GetPendingCreditsUseCase } from './application/use-cases/get-pending-credits.use-case'; +// User-level use cases +import { RegisterUserReferralUseCase } from './application/use-cases/register-user-referral.use-case'; +import { ConsumeUserPaymentUseCase } from './application/use-cases/consume-user-payment.use-case'; +import { GetMyUserReferralInfoUseCase } from './application/use-cases/get-my-user-referral-info.use-case'; +import { GetMyCircleUseCase } from './application/use-cases/get-my-circle.use-case'; // Controllers import { ReferralController } from './interfaces/rest/controllers/referral.controller'; @@ -48,20 +57,29 @@ import { ReferralAdminController } from './interfaces/rest/controllers/referral- ReferralAdminController, ], providers: [ - // Repositories + // Tenant-level repositories (B2B) ReferralCodeRepository, ReferralRelationshipRepository, ReferralRewardRepository, ReferralStatRepository, ProcessedEventRepository, + // User-level repositories (C2C personal circle + points) + UserReferralCodeRepository, + UserReferralRelationshipRepository, + UserPointRepository, - // Use Cases + // Tenant-level use cases GetMyReferralInfoUseCase, ValidateReferralCodeUseCase, RegisterWithCodeUseCase, ConsumePaymentReceivedUseCase, GetReferralListUseCase, GetPendingCreditsUseCase, + // User-level use cases + RegisterUserReferralUseCase, + ConsumeUserPaymentUseCase, + GetMyUserReferralInfoUseCase, + GetMyCircleUseCase, ], }) export class ReferralModule {} diff --git a/packages/shared/database/migrations/011-user-referral-points.sql b/packages/shared/database/migrations/011-user-referral-points.sql new file mode 100644 index 0000000..272413d --- /dev/null +++ b/packages/shared/database/migrations/011-user-referral-points.sql @@ -0,0 +1,77 @@ +-- Migration 011: User-level referral codes + personal circle + points system +-- Extends the existing tenant-level referral system (006) to support +-- individual user-to-user referrals and a unified points economy. +-- +-- Design: +-- • Each user gets their own referral code (vs. per-tenant in 006) +-- • Referral relationships track user→user (personal circle, max 2 levels) +-- • Points replace raw USD credits as the reward currency (flexible redemption) +-- • Points ledger is append-only; balance is a denormalized cache + +-- ── 1. User referral codes ────────────────────────────────────────────────── +-- One row per user. Created automatically on first registration. +CREATE TABLE IF NOT EXISTS public.user_referral_codes ( + user_id UUID PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + click_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── 2. User referral relationships (personal circle) ──────────────────────── +-- Tracks who invited whom at the user level. +-- Each user can be referred at most once (UNIQUE referred_user_id). +-- level: 1 = direct invite, 2 = indirect (friend of friend). +-- status: PENDING → ACTIVE (first engagement) → REWARDED (first payment done) +-- EXPIRED (never activated after 90 days) +CREATE TABLE IF NOT EXISTS public.user_referral_relationships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + referrer_user_id UUID NOT NULL, + referred_user_id UUID NOT NULL UNIQUE, + referral_code VARCHAR(20) NOT NULL, + level SMALLINT NOT NULL DEFAULT 1 CHECK (level IN (1, 2)), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING','ACTIVE','REWARDED','EXPIRED')), + activated_at TIMESTAMPTZ, + rewarded_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_urr_referrer ON public.user_referral_relationships(referrer_user_id); +CREATE INDEX IF NOT EXISTS idx_urr_status ON public.user_referral_relationships(status); +CREATE INDEX IF NOT EXISTS idx_urr_created ON public.user_referral_relationships(created_at); + +-- ── 3. Points ledger (append-only) ────────────────────────────────────────── +-- Every point event is a row. delta > 0 = earned, delta < 0 = spent. +-- type values: +-- REFERRAL_FIRST_PAYMENT – first subscription payment by referred user +-- REFERRAL_RECURRING – recurring monthly payment reward (≤12 months) +-- REFERRAL_L2 – level-2 indirect reward (≤6 months) +-- REFERRAL_WELCOME – welcome bonus awarded to newly referred user +-- REDEMPTION_QUOTA – redeemed for extra usage quota +-- REDEMPTION_UNLOCK – redeemed to unlock a premium agent +-- ADMIN_GRANT – platform admin manual grant/deduction +-- EXPIRY – points expiry deduction +CREATE TABLE IF NOT EXISTS public.user_point_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + delta INT NOT NULL, + type VARCHAR(40) NOT NULL, + ref_id UUID, -- FK to user_referral_relationships.id or redemption id + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_upt_user_id ON public.user_point_transactions(user_id); +CREATE INDEX IF NOT EXISTS idx_upt_created ON public.user_point_transactions(created_at); +CREATE INDEX IF NOT EXISTS idx_upt_ref_id ON public.user_point_transactions(ref_id); + +-- ── 4. Points balance (denormalized cache) ─────────────────────────────────── +-- Maintained atomically alongside user_point_transactions. +-- balance = total_earned + total_spent (total_spent is always ≤ 0, so balance = earned - |spent|) +CREATE TABLE IF NOT EXISTS public.user_point_balances ( + user_id UUID PRIMARY KEY, + balance INT NOT NULL DEFAULT 0, + total_earned INT NOT NULL DEFAULT 0, + total_spent INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +);