feat(refer) : Implement referral system with UI components and API integration (#430)

* Implement referral system with UI components and API integration

- Added  to document the referral system, including reward structure, rules, data model, migration, core services, and API endpoints.
- Created  component to display referral stats and allow users to apply referral codes.
- Integrated referral functionality into the onboarding process with  for applying referral codes.
- Updated  page to include the new .
- Implemented API calls in  for fetching referral stats and applying referral codes.
- Added tests for the referral API to ensure proper functionality and data normalization.
- Introduced device fingerprinting for referral code application to prevent abuse.

This commit establishes a comprehensive referral system, enhancing user engagement and incentivizing referrals.

* Update OLLAMA_BASE_URL to local development address

* Enhance referral system and onboarding process

- Added a new function to format reward rates from basis points to percentage for better display in the ReferralRewardsSection.
- Improved loading state management in the referral stats loading process to prevent race conditions.
- Updated the Onboarding component to handle referral step skipping more effectively, ensuring a smoother user experience.
- Fixed a typo in the WelcomeStep component's button label for clarity.
- Enhanced error handling in the referral API to provide clearer feedback on failures.

These changes improve the usability and reliability of the referral system and onboarding experience.
This commit is contained in:
YellowSnnowmann
2026-04-08 20:16:46 +05:30
committed by GitHub
parent 2e7d1946b0
commit 45a821645a
16 changed files with 1536 additions and 14 deletions
+108
View File
@@ -0,0 +1,108 @@
# Referral System
## Overview
Referral rewards are paid to the referrer based on real successful payments from referred users.
- Reward rate: `20%` of payment (`2000` basis points)
- Referred user reward: none
- Behavior flag: `RECURRING_REFERRAL_REWARD`
- `true`: reward every successful eligible payment
- `false`: reward only once per referral
## Main Rules
- Each user has one unique referral code.
- A user can apply a code only before their first confirmed payment.
- Self-referral is blocked (user id + identity fields).
- Rewarding is idempotent:
- same payment cannot reward twice
- non-recurring mode allows only one reward for that referral
## Data Model
### `ReferralCode` (`referralcodes`)
- `userId` (unique)
- `referralCode` (unique)
### `Referral` (`referrals`)
- `referrerId`
- `referredUserId` (unique)
- `referralCode`
- `status`: `pending | converted`
- `sourceIp`, `deviceFingerprint`, `convertedAt`
### `ReferralTransaction` (`referraltransactions`)
- `referralId`, `referrerId`, `referredUserId`
- `sourcePaymentId`, `sourcePaymentGateway`, `sourcePaymentObjectId`
- `paymentAmountUsd`, `rewardAmountUsd`, `rewardRate` (Decimal128)
- `creditTransactionId`
- `idempotencyKey` (unique)
## Migration
Migration file: `src/migrations/1744200000000-referral-system.ts`
What it does:
- creates indexes for referral collections
- backfills missing referral codes for existing users
- backfills `Referral` records from legacy `user.referral.invitedBy`
- supports `users` and `tgusers` collections
### Run migration (non-interactive)
```bash
npx ts-migrate-mongoose up -f src/migrate.ts -a true
```
### Check migration status
```bash
npm run migrate:list
```
### Roll back referral migration (if needed)
```bash
npm run migrate:down
```
## Core Services
- `src/services/referral/referralCodeService.ts`
- ensures and fetches user referral codes
- `src/services/referral/referralService.ts`
- apply code, enforce eligibility, return referral stats
- `src/services/referral/referralRewardService.ts`
- compute reward in cents, award credits, upsert audit transaction, mark referral converted
## API
- `GET /referral/stats`
- returns code, referral link, totals, and referral rows
- `POST /referral/apply`
- request: `{ "code": "ABCD1234", "deviceFingerprint": "optional" }`
- supports `x-device-fingerprint` header
## Payment Integration
Reward processing is triggered on successful payment flows in:
- `src/controllers/payment/coinbase/webhook.ts`
- `src/controllers/payment/stripe/handleWebhook.ts`
## Config
`RECURRING_REFERRAL_REWARD` is read via `nconf`. Truthy values: `true`, `1`, `yes`.
## Tests
Key test: `src/services/referral/__tests__/referralRewardService.test.ts`
- non-recurring behavior
- recurring behavior
- payment idempotency
@@ -0,0 +1,359 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useUser } from '../../hooks/useUser';
import { useCoreState } from '../../providers/CoreStateProvider';
import { referralApi } from '../../services/api/referralApi';
import type { ReferralRelationshipStatus, ReferralStats } from '../../types/referral';
function formatUsd(n: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
}
/** Basis points → percent for display (100 bps = 1%). */
function formatRewardRatePercentFromBps(bps: number): string {
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(bps / 100);
}
function statusBadgeClass(status: ReferralRelationshipStatus): string {
switch (status) {
case 'converted':
return 'bg-sage-100 text-sage-800';
case 'expired':
return 'bg-stone-100 text-stone-600';
default:
return 'bg-amber-50 text-amber-800';
}
}
function statusLabel(status: ReferralRelationshipStatus): string {
switch (status) {
case 'converted':
return 'Completed';
case 'expired':
return 'Expired';
default:
return 'Joined';
}
}
const ReferralRewardsSection = () => {
const { user, refetch } = useUser();
const { snapshot } = useCoreState();
const token = snapshot.sessionToken;
const [stats, setStats] = useState<ReferralStats | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [applyCode, setApplyCode] = useState('');
const [applyLoading, setApplyLoading] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [applySuccess, setApplySuccess] = useState(false);
const [copyHint, setCopyHint] = useState<string | null>(null);
const latestRequestIdRef = useRef(0);
const loadStats = useCallback(async () => {
if (!token) {
latestRequestIdRef.current += 1;
setLoading(false);
return;
}
latestRequestIdRef.current += 1;
const requestId = latestRequestIdRef.current;
setLoading(true);
setLoadError(null);
try {
const s = await referralApi.getStats();
if (requestId !== latestRequestIdRef.current) return;
setStats(s);
console.debug('[referral-ui] stats', {
codeLen: s.referralCode.length,
referrals: s.referrals.length,
});
} catch (err) {
if (requestId !== latestRequestIdRef.current) return;
const msg =
err && typeof err === 'object' && 'error' in err
? String((err as { error: string }).error)
: 'Could not load referral stats';
setLoadError(msg);
console.debug('[referral-ui] stats error', msg);
} finally {
if (requestId === latestRequestIdRef.current) {
setLoading(false);
}
}
}, [token]);
useEffect(() => {
void loadStats();
}, [loadStats]);
const shareOrCopyTarget = stats?.referralLink?.trim() || stats?.referralCode || '';
const handleCopy = async () => {
if (!shareOrCopyTarget) return;
try {
await navigator.clipboard.writeText(shareOrCopyTarget);
setCopyHint('Copied');
setTimeout(() => setCopyHint(null), 2000);
} catch {
setCopyHint('Copy failed');
setTimeout(() => setCopyHint(null), 2500);
}
};
const handleShare = async () => {
if (!shareOrCopyTarget) return;
try {
if (navigator.share) {
const url = stats?.referralLink?.trim();
await navigator.share({
title: 'OpenHuman',
text: url
? 'Join me on OpenHuman'
: `Join me on OpenHuman — referral code: ${stats?.referralCode ?? ''}`,
...(url ? { url } : {}),
});
} else {
await handleCopy();
}
} catch (e) {
if ((e as Error)?.name !== 'AbortError') {
await handleCopy();
}
}
};
const handleApply = async () => {
const trimmed = applyCode.trim();
if (!trimmed) return;
setApplyLoading(true);
setApplyError(null);
try {
await referralApi.applyCode(trimmed);
setApplySuccess(true);
setApplyCode('');
await refetch();
await loadStats();
console.debug('[referral-ui] apply completed');
} catch (err) {
const msg =
err && typeof err === 'object' && 'error' in err
? String((err as { error: string }).error)
: 'Could not apply referral code';
setApplyError(msg);
} finally {
setApplyLoading(false);
}
};
const hasAppliedFromProfile = !!user?.referral?.invitedBy || !!user?.referral?.invitedByCode;
const hasAppliedFromStats =
!!stats?.appliedReferralCode && stats.appliedReferralCode.trim() !== '';
const showApplyForm =
stats &&
stats.canApplyReferral !== false &&
!hasAppliedFromStats &&
!hasAppliedFromProfile &&
!applySuccess;
if (!token) {
return null;
}
return (
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-800">
Referral rewards
</div>
<h2 className="text-2xl font-semibold text-stone-900">Invite friends, earn credits</h2>
<p className="text-sm text-stone-600 max-w-xl">
Share your personal link. When someone subscribes, you can earn a share of their
eligible payments as account credit. Self-referrals and duplicate rewards are blocked on
the server.
</p>
{stats?.rewardRateBps ? (
<p className="text-xs text-stone-500">
Current reward rate: {formatRewardRatePercentFromBps(stats.rewardRateBps)}% of
eligible referred payments (basis points {stats.rewardRateBps}).
</p>
) : null}
</div>
</div>
{loading && !stats ? (
<p className="text-sm text-stone-500">Loading referral program</p>
) : null}
{loadError ? (
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
{loadError}
<button
type="button"
onClick={() => void loadStats()}
className="ml-2 underline font-medium">
Retry
</button>
</div>
) : null}
{stats ? (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
Your code
</div>
<div className="mt-2 font-mono text-lg font-semibold text-stone-900 break-all">
{stats.referralCode || '—'}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
Total earned
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
{formatUsd(stats.totals.totalRewardUsd)}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
Pending referrals
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
{stats.totals.pendingCount}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
Completed
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
{stats.totals.convertedCount}
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<button
type="button"
onClick={() => void handleCopy()}
disabled={!shareOrCopyTarget}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-stone-900 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-stone-800 disabled:opacity-50">
Copy link or code
</button>
<button
type="button"
onClick={() => void handleShare()}
disabled={!shareOrCopyTarget}
className="inline-flex items-center justify-center rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50 disabled:opacity-50">
Share
</button>
{copyHint ? (
<span className="self-center text-sm text-sage-600">{copyHint}</span>
) : null}
</div>
{stats.referralLink ? (
<p className="text-xs text-stone-500 break-all font-mono">{stats.referralLink}</p>
) : null}
<div>
<h3 className="text-sm font-semibold text-stone-900 mb-2">Referral activity</h3>
{stats.referrals.length === 0 ? (
<p className="text-sm text-stone-500 rounded-xl border border-dashed border-stone-200 px-4 py-6 text-center">
No referrals yet. Share your link to get started.
</p>
) : (
<div className="overflow-x-auto rounded-xl border border-stone-200">
<table className="min-w-full text-sm text-left">
<thead className="bg-stone-50 text-xs uppercase tracking-wide text-stone-500">
<tr>
<th className="px-3 py-2 font-medium">Referred user</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Reward</th>
<th className="px-3 py-2 font-medium">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{stats.referrals.map((row, idx) => (
<tr key={row.id ?? row.referredUserId ?? idx} className="bg-white">
<td className="px-3 py-2 font-mono text-stone-800">
{row.referredUserMasked || row.referredDisplayName || '—'}
</td>
<td className="px-3 py-2">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${statusBadgeClass(row.status)}`}>
{statusLabel(row.status)}
</span>
</td>
<td className="px-3 py-2 text-stone-700">
{row.rewardUsd != null && row.rewardUsd > 0
? formatUsd(row.rewardUsd)
: '—'}
</td>
<td className="px-3 py-2 text-stone-500 text-xs">
{row.status === 'converted' && row.convertedAt
? new Date(row.convertedAt).toLocaleString()
: row.createdAt
? new Date(row.createdAt).toLocaleString()
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{showApplyForm ? (
<div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Have a referral code?</h3>
<p className="text-xs text-stone-600">
Enter a friend&apos;s code if you haven&apos;t completed a paid subscription yet.
Eligibility is enforced by the server.
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="text"
value={applyCode}
onChange={e => setApplyCode(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && void handleApply()}
placeholder="Referral code"
disabled={applyLoading}
className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
/>
<button
type="button"
onClick={() => void handleApply()}
disabled={applyLoading || !applyCode.trim()}
className="rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50">
{applyLoading ? 'Applying…' : 'Apply'}
</button>
</div>
{applyError ? <p className="text-xs text-coral-600">{applyError}</p> : null}
</div>
) : null}
{(hasAppliedFromStats || hasAppliedFromProfile || applySuccess) && !showApplyForm ? (
<p className="text-sm text-sage-700 rounded-xl border border-sage-200 bg-sage-50 px-3 py-2">
You&apos;re linked to a referral program
{stats.appliedReferralCode ? ` (code ${stats.appliedReferralCode})` : ''}.
</p>
) : null}
</>
) : null}
</div>
);
};
export default ReferralRewardsSection;
+3
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import ReferralRewardsSection from '../components/referral/ReferralRewardsSection';
import { useUser } from '../hooks/useUser';
import { useAppSelector } from '../store/hooks';
@@ -89,6 +90,8 @@ const Rewards = () => {
return (
<div className="min-h-full px-4 pt-6 pb-8">
<div className="max-w-3xl mx-auto space-y-4">
<ReferralRewardsSection />
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6">
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
+122 -11
View File
@@ -1,8 +1,11 @@
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import ProgressIndicator from '../../components/ProgressIndicator';
import { useCoreState } from '../../providers/CoreStateProvider';
import { referralApi } from '../../services/api/referralApi';
import { userApi } from '../../services/api/userApi';
import { getDefaultEnabledTools } from '../../utils/toolDefinitions';
import ReferralApplyStep from './steps/ReferralApplyStep';
import ScreenPermissionsStep from './steps/ScreenPermissionsStep';
import SkillsStep from './steps/SkillsStep';
import WelcomeStep from './steps/WelcomeStep';
@@ -17,25 +20,115 @@ interface OnboardingDraft {
connectedSources: string[];
}
function hasReferralFromProfile(
user:
| { referral?: { invitedBy?: string | null; invitedByCode?: string | null } }
| null
| undefined
): boolean {
return !!(user?.referral?.invitedBy || user?.referral?.invitedByCode);
}
/** When referral is skipped, step index 1 (apply) is not shown — treat as screen permissions (2). */
function resolveOnboardingStep(currentStep: number, skipReferralStep: boolean): number {
if (skipReferralStep && currentStep === 1) {
return 2;
}
return currentStep;
}
const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
const { setOnboardingCompletedFlag, setOnboardingTasks } = useCoreState();
const { setOnboardingCompletedFlag, setOnboardingTasks, snapshot } = useCoreState();
const [currentStep, setCurrentStep] = useState(0);
const [draft, setDraft] = useState<OnboardingDraft>({
accessibilityPermissionGranted: false,
connectedSources: [],
});
const totalSteps = 3;
/** Last session token for which referral stats prefetch finished (async path only). */
const [referralStatsToken, setReferralStatsToken] = useState<string | null>(null);
const [skipReferralFromStats, setSkipReferralFromStats] = useState(false);
const [referralAppliedThisSession, setReferralAppliedThisSession] = useState(false);
const token = snapshot.sessionToken;
const currentUser = snapshot.currentUser;
const profileAlreadyReferred = useMemo(() => hasReferralFromProfile(currentUser), [currentUser]);
const needsReferralStatsPrefetch = !!(token && !profileAlreadyReferred);
useEffect(() => {
if (!needsReferralStatsPrefetch) {
return;
}
let cancelled = false;
(async () => {
try {
const stats = await referralApi.getStats();
const applied =
typeof stats.appliedReferralCode === 'string' && stats.appliedReferralCode.trim() !== '';
if (!cancelled) {
setSkipReferralFromStats(applied);
setReferralStatsToken(token);
}
} catch {
console.debug('[onboarding] referral preflight failed; showing referral step');
if (!cancelled) {
setSkipReferralFromStats(false);
setReferralStatsToken(token);
}
}
})();
return () => {
cancelled = true;
};
}, [needsReferralStatsPrefetch, token, profileAlreadyReferred]);
const referralGateReady = !token || profileAlreadyReferred || referralStatsToken === token;
const skipReferralStep = !token
? false
: profileAlreadyReferred
? true
: referralStatsToken === token && skipReferralFromStats;
const resolvedStep = resolveOnboardingStep(currentStep, skipReferralStep);
const totalSteps = skipReferralStep ? 3 : 4;
const progressCurrentStep = skipReferralStep
? resolvedStep === 0
? 0
: resolvedStep === 2
? 1
: 2
: resolvedStep;
const handleWelcomeNext = () => {
if (skipReferralStep) {
setCurrentStep(2);
} else {
setCurrentStep(1);
}
};
const handleNext = () => {
if (currentStep < totalSteps - 1) {
setCurrentStep(currentStep + 1);
const logical = resolveOnboardingStep(currentStep, skipReferralStep);
if (logical < 3) {
setCurrentStep(logical + 1);
}
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
const logical = resolveOnboardingStep(currentStep, skipReferralStep);
if (logical <= 0) return;
if (
logical === 2 &&
(skipReferralStep || profileAlreadyReferred || referralAppliedThisSession)
) {
setCurrentStep(0);
return;
}
setCurrentStep(logical - 1);
};
const handleAccessibilityNext = (accessibilityPermissionGranted: boolean) => {
@@ -73,12 +166,27 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
};
const renderStep = () => {
switch (currentStep) {
switch (resolvedStep) {
case 0:
return <WelcomeStep onNext={handleNext} />;
return (
<WelcomeStep
onNext={handleWelcomeNext}
nextDisabled={!referralGateReady}
nextLoading={!!token && !referralGateReady}
nextLoadingLabel="Checking account…"
/>
);
case 1:
return <ScreenPermissionsStep onNext={handleAccessibilityNext} onBack={handleBack} />;
return (
<ReferralApplyStep
onNext={handleNext}
onBack={handleBack}
onApplied={() => setReferralAppliedThisSession(true)}
/>
);
case 2:
return <ScreenPermissionsStep onNext={handleAccessibilityNext} onBack={handleBack} />;
case 3:
return <SkillsStep onNext={handleSkillsNext} onBack={handleBack} />;
default:
return null;
@@ -97,7 +205,10 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
</button>
</div>
)}
<div className="relative z-10 max-w-lg w-full mx-4">{renderStep()}</div>
<div className="relative z-10 max-w-lg w-full mx-4">
<ProgressIndicator currentStep={progressCurrentStep} totalSteps={totalSteps} />
{renderStep()}
</div>
</div>
);
};
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { referralApi } from '../../../services/api/referralApi';
interface ReferralApplyStepProps {
onNext: () => void;
onBack: () => void;
/** Called after a successful apply so onboarding can skip showing this step when navigating back. */
onApplied?: () => void;
}
/**
* Optional step: attribute the signed-in user to a referrer via POST /referral/apply.
*/
const ReferralApplyStep = ({ onNext, onBack, onApplied }: ReferralApplyStepProps) => {
const { refresh } = useCoreState();
const [code, setCode] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleApply = async () => {
const trimmed = code.trim();
if (!trimmed) return;
setIsLoading(true);
setError(null);
try {
await referralApi.applyCode(trimmed);
setSuccess(true);
try {
await refresh();
} catch {
console.warn('[onboarding] referral apply: refresh after apply failed');
}
onApplied?.();
console.debug('[onboarding] referral code applied');
setTimeout(() => onNext(), 1200);
} catch (err) {
const msg =
err && typeof err === 'object' && 'error' in err
? String((err as { error: string }).error)
: 'Could not apply referral code';
setError(msg);
} finally {
setIsLoading(false);
}
};
return (
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
<div className="text-center mb-6">
<h1 className="text-xl font-bold mb-2 text-stone-900">Referral code</h1>
<p className="text-stone-600 text-sm">
If a friend shared OpenHuman with you, enter their referral code here. You can skipthis
stays available on the Rewards page while you&apos;re eligible.
</p>
</div>
{success ? (
<div className="text-center py-4">
<div className="w-12 h-12 bg-sage-50 rounded-full flex items-center justify-center mx-auto mb-3">
<svg
className="w-6 h-6 text-sage-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<p className="text-sage-600 font-medium text-sm">Referral code applied.</p>
</div>
) : (
<>
<div className="mb-4">
<input
type="text"
value={code}
onChange={e => setCode(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && void handleApply()}
placeholder="Enter referral code"
className="w-full px-4 py-3 bg-stone-50 border border-stone-200 rounded-xl text-center font-mono text-lg tracking-widest text-stone-900 placeholder:text-stone-400 placeholder:tracking-normal placeholder:font-sans placeholder:text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
disabled={isLoading}
/>
{error ? <p className="text-coral-500 text-xs mt-2 text-center">{error}</p> : null}
</div>
<div className="space-y-2">
<button
type="button"
onClick={() => void handleApply()}
disabled={isLoading || !code.trim()}
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed">
{isLoading ? 'Applying…' : 'Apply code'}
</button>
<button
type="button"
onClick={onNext}
disabled={isLoading}
className="w-full py-2.5 text-sm font-medium rounded-xl text-stone-400 hover:text-stone-700 transition-colors">
Skip for now
</button>
<button
type="button"
onClick={onBack}
disabled={isLoading}
className="w-full py-2 text-sm text-stone-500 hover:text-stone-800 transition-colors">
Back
</button>
</div>
</>
)}
</div>
);
};
export default ReferralApplyStep;
+16 -2
View File
@@ -5,6 +5,9 @@ import OnboardingNextButton from '../components/OnboardingNextButton';
interface WelcomeStepProps {
onNext: () => void;
nextDisabled?: boolean;
nextLoading?: boolean;
nextLoadingLabel?: string;
}
const TOTAL_SLIDES = 3;
@@ -63,7 +66,12 @@ const AutomationSlide = () => (
/* ------------------------------------------------------------------ */
/* WelcomeStep — auto-advancing carousel, button goes to next step */
/* ------------------------------------------------------------------ */
const WelcomeStep = ({ onNext }: WelcomeStepProps) => {
const WelcomeStep = ({
onNext,
nextDisabled = false,
nextLoading = false,
nextLoadingLabel,
}: WelcomeStepProps) => {
const [slide, setSlide] = useState(0);
useEffect(() => {
@@ -83,7 +91,13 @@ const WelcomeStep = ({ onNext }: WelcomeStepProps) => {
<div className="mt-8 mb-6 flex justify-center">
<ProgressIndicator currentStep={slide} totalSteps={TOTAL_SLIDES} />
</div>
<OnboardingNextButton label="Let Start" onClick={onNext} />
<OnboardingNextButton
label="Let's Start"
onClick={onNext}
disabled={nextDisabled}
loading={nextLoading}
loadingLabel={nextLoadingLabel}
/>
</div>
);
};
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest';
import { normalizeReferralStats, referralApi } from '../referralApi';
vi.mock('../../coreCommandClient', () => ({ callCoreCommand: vi.fn() }));
describe('normalizeReferralStats', () => {
it('maps camelCase stats and referral rows', () => {
const stats = normalizeReferralStats({
referralCode: 'ABC12',
referralLink: 'https://app.example/r/ABC12',
totals: { totalRewardUsd: 12.5, pendingCount: 1, convertedCount: 2 },
referrals: [
{ referredUserId: 'u1', status: 'pending', createdAt: '2025-01-01' },
{ referredUserId: 'u2', status: 'converted', convertedAt: '2025-01-02', rewardUsd: 5 },
],
appliedReferralCode: null,
canApplyReferral: true,
rewardRateBps: 2000,
});
expect(stats.referralCode).toBe('ABC12');
expect(stats.referralLink).toBe('https://app.example/r/ABC12');
expect(stats.totals).toEqual({ totalRewardUsd: 12.5, pendingCount: 1, convertedCount: 2 });
expect(stats.referrals).toHaveLength(2);
expect(stats.referrals[0].status).toBe('pending');
expect(stats.referrals[1].status).toBe('converted');
expect(stats.referrals[1].rewardUsd).toBe(5);
expect(stats.appliedReferralCode).toBeNull();
expect(stats.canApplyReferral).toBe(true);
expect(stats.rewardRateBps).toBe(2000);
});
it('maps snake_case and coerces unknown status to pending', () => {
const stats = normalizeReferralStats({
code: 'X',
link: 'https://x',
summary: { total_reward_usd: '3.25', pending_referrals: 2, converted_referrals: 0 },
referralRows: [{ status: 'weird', _id: 'r1' }],
});
expect(stats.referralCode).toBe('X');
expect(stats.totals.totalRewardUsd).toBe(3.25);
expect(stats.totals.pendingCount).toBe(2);
expect(stats.referrals[0].status).toBe('pending');
expect(stats.referrals[0].id).toBe('r1');
});
it('handles empty payload', () => {
const stats = normalizeReferralStats({});
expect(stats.referralCode).toBe('');
expect(stats.referrals).toEqual([]);
expect(stats.totals.totalRewardUsd).toBe(0);
});
it('maps completed status to converted and rewardAmountUsd', () => {
const stats = normalizeReferralStats({
referrals: [{ status: 'Completed', rewardAmountUsd: 2.5, referredUserId: 'u1' }],
totals: { totalRewardUsd: 0, pendingCount: 0, convertedCount: 0 },
});
expect(stats.referrals[0].status).toBe('converted');
expect(stats.referrals[0].rewardUsd).toBe(2.5);
expect(stats.totals.convertedCount).toBe(1);
expect(stats.totals.totalRewardUsd).toBe(2.5);
});
it('maps referralId, joinedAt, referredUserMasked, and Joined status', () => {
const stats = normalizeReferralStats({
referrals: [
{
referralId: 'ref-99',
status: 'Joined',
referredUserMasked: ' j***@gmail.com ',
joinedAt: '2026-04-01T12:00:00.000Z',
convertedAt: null,
},
],
});
expect(stats.referrals[0].id).toBe('ref-99');
expect(stats.referrals[0].referredUserMasked).toBe('j***@gmail.com');
expect(stats.referrals[0].status).toBe('pending');
expect(stats.referrals[0].createdAt).toBe('2026-04-01T12:00:00.000Z');
});
it('maps referred_user_masked snake_case', () => {
const stats = normalizeReferralStats({
referrals: [{ referred_user_masked: 'U***', status: 'Converted' }],
});
expect(stats.referrals[0].referredUserMasked).toBe('U***');
expect(stats.referrals[0].status).toBe('converted');
});
it('reads Mongo-style Decimal128 and nested transactions', () => {
const stats = normalizeReferralStats({
referrals: [
{
status: 'converted',
referred_user_id: { $oid: '507f1f77bcf86cd799439011' },
transactions: [
{ rewardAmountUsd: { $numberDecimal: '1.25' } },
{ reward_amount_usd: '0.75' },
],
},
],
});
expect(stats.referrals[0].referredUserId).toBe('507f1f77bcf86cd799439011');
expect(stats.referrals[0].rewardUsd).toBe(2);
expect(stats.totals.totalRewardUsd).toBe(2);
expect(stats.totals.convertedCount).toBe(1);
});
it('prefers explicit totals when backend sends them', () => {
const stats = normalizeReferralStats({
totals: { totalRewardUsd: 10, pendingCount: 0, convertedCount: 2 },
referrals: [{ status: 'converted', rewardUsd: 3 }],
});
expect(stats.totals.totalRewardUsd).toBe(10);
expect(stats.totals.convertedCount).toBe(2);
});
it('maps totalRewardsEarnedUsd from backend stats payload', () => {
const stats = normalizeReferralStats({
totals: { totalRewardsEarnedUsd: 4.5, pendingCount: 0, convertedCount: 1 },
referrals: [{ status: 'converted' }],
});
expect(stats.totals.totalRewardUsd).toBe(4.5);
});
});
describe('referralApi', () => {
it('getStats normalizes core RPC payload', async () => {
const { callCoreCommand } = await import('../../coreCommandClient');
vi.mocked(callCoreCommand).mockResolvedValueOnce({
referralCode: 'Z9',
referralLink: 'https://z',
totals: { totalRewardUsd: 1, pendingCount: 0, convertedCount: 1 },
referrals: [],
});
const out = await referralApi.getStats();
expect(callCoreCommand).toHaveBeenCalledWith('openhuman.referral_get_stats');
expect(out.referralCode).toBe('Z9');
});
it('applyCode calls core with trimmed code and fingerprint', async () => {
const { callCoreCommand } = await import('../../coreCommandClient');
vi.mocked(callCoreCommand).mockResolvedValueOnce({});
await referralApi.applyCode(' abcd ');
expect(callCoreCommand).toHaveBeenCalledWith(
'openhuman.referral_apply',
expect.objectContaining({ code: 'abcd', deviceFingerprint: expect.any(String) })
);
});
it('getStats throws { success: false, error } when core rejects with Error', async () => {
const { callCoreCommand } = await import('../../coreCommandClient');
vi.mocked(callCoreCommand).mockRejectedValueOnce(new Error('Core RPC HTTP 503'));
await expect(referralApi.getStats()).rejects.toEqual({
success: false,
error: 'Core RPC HTTP 503',
});
});
it('applyCode throws { success: false, error } preserving err.error string', async () => {
const { callCoreCommand } = await import('../../coreCommandClient');
vi.mocked(callCoreCommand).mockRejectedValueOnce({ error: 'Code already used' });
await expect(referralApi.applyCode('ABCD')).rejects.toEqual({
success: false,
error: 'Code already used',
});
});
});
+324
View File
@@ -0,0 +1,324 @@
import type {
ReferralRelationshipStatus,
ReferralRow,
ReferralStats,
ReferralStatsTotals,
} from '../../types/referral';
import { getOrCreateDeviceFingerprint } from '../../utils/deviceFingerprint';
import { callCoreCommand } from '../coreCommandClient';
/** Shape thrown by {@link referralApi.getStats} / {@link referralApi.applyCode} on RPC failure. */
export type ReferralRpcFailure = { success: false; error: string };
function referralRpcErrorMessage(err: unknown): string {
if (err && typeof err === 'object') {
const o = err as Record<string, unknown>;
if (typeof o.error === 'string' && o.error.trim() !== '') {
return o.error;
}
if (typeof o.message === 'string' && o.message.trim() !== '') {
return o.message;
}
}
if (err instanceof Error && err.message) {
return err.message;
}
return String(err);
}
function throwReferralRpcFailure(err: unknown): never {
const failure: ReferralRpcFailure = { success: false, error: referralRpcErrorMessage(err) };
throw failure;
}
function num(v: unknown): number {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '') {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
return 0;
}
/** Mongo Decimal128 in JSON (`{ $numberDecimal: "1.23" }`) and similar. */
function coerceMoney(v: unknown): number {
if (v === null || v === undefined) return 0;
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '') {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
const o = asRecord(v);
if (o && typeof o.$numberDecimal === 'string') {
return num(o.$numberDecimal);
}
return 0;
}
function coerceId(v: unknown): string | undefined {
if (typeof v === 'string' && v.trim()) return v.trim();
const o = asRecord(v);
if (o && typeof o.$oid === 'string' && o.$oid.trim()) return o.$oid.trim();
return undefined;
}
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
function normalizeStatus(raw: unknown): ReferralRelationshipStatus {
const s = typeof raw === 'string' ? raw.toLowerCase().trim() : '';
if (s === 'converted' || s === 'completed' || s === 'complete') return 'converted';
if (s === 'joined') return 'pending';
if (s === 'pending' || s === 'expired') return s;
return 'pending';
}
function rowRewardUsd(r: Record<string, unknown>): number {
const direct = coerceMoney(
r.rewardUsd ??
r.reward_usd ??
r.rewardAmountUsd ??
r.reward_amount_usd ??
r.totalRewardUsd ??
r.total_reward_usd
);
const cents = num(r.rewardCents ?? r.reward_cents);
const fromCents = cents > 0 ? cents / 100 : 0;
const txs = r.transactions ?? r.referralTransactions ?? r.referral_transactions;
let txSum = 0;
if (Array.isArray(txs)) {
for (const t of txs) {
const tr = asRecord(t) ?? {};
const m = coerceMoney(tr.rewardAmountUsd ?? tr.reward_amount_usd ?? tr.rewardUsd);
const c = num(tr.rewardCents ?? tr.reward_cents);
txSum += m > 0 ? m : c > 0 ? c / 100 : 0;
}
}
if (txSum > 0) return txSum;
if (direct > 0) return direct;
return fromCents;
}
function normalizeRow(entry: unknown): ReferralRow {
const r = asRecord(entry) ?? {};
const refUser = asRecord(r.referredUser ?? r.referred_user ?? r.user);
const rewardUsd = rowRewardUsd(r);
const referredUserId =
(typeof r.referredUserId === 'string' && r.referredUserId) ||
(typeof r.referred_user_id === 'string' && r.referred_user_id) ||
(typeof r.refereeId === 'string' && r.refereeId) ||
(typeof r.referee_id === 'string' && r.referee_id) ||
coerceId(r.referredUserId) ||
coerceId(r.referred_user_id) ||
(refUser
? (coerceId(refUser._id) ?? (typeof refUser.id === 'string' ? refUser.id : undefined))
: undefined);
const referredDisplayName =
typeof r.referredDisplayName === 'string'
? r.referredDisplayName
: typeof r.referred_display_name === 'string'
? r.referred_display_name
: typeof r.referredUsername === 'string'
? r.referredUsername
: refUser && typeof refUser.username === 'string'
? refUser.username
: undefined;
const referredUserMaskedRaw =
typeof r.referredUserMasked === 'string'
? r.referredUserMasked
: typeof r.referred_user_masked === 'string'
? r.referred_user_masked
: undefined;
const referredUserMasked =
referredUserMaskedRaw && referredUserMaskedRaw.trim() !== ''
? referredUserMaskedRaw.trim()
: undefined;
return {
id:
(typeof r.referralId === 'string' && r.referralId) ||
(typeof r.referral_id === 'string' && r.referral_id) ||
(typeof r.id === 'string' && r.id) ||
(typeof r._id === 'string' && r._id) ||
coerceId(r._id),
referredUserId,
status: normalizeStatus(r.status),
referralCode:
typeof r.referralCode === 'string'
? r.referralCode
: typeof r.referral_code === 'string'
? r.referral_code
: undefined,
createdAt:
typeof r.joinedAt === 'string'
? r.joinedAt
: typeof r.joined_at === 'string'
? r.joined_at
: typeof r.createdAt === 'string'
? r.createdAt
: typeof r.created_at === 'string'
? r.created_at
: undefined,
convertedAt:
r.convertedAt === null
? null
: typeof r.convertedAt === 'string'
? r.convertedAt
: typeof r.converted_at === 'string'
? r.converted_at
: undefined,
rewardUsd: rewardUsd > 0 ? rewardUsd : undefined,
referredDisplayName,
referredUserMasked,
};
}
function deriveTotalsFromReferrals(referrals: ReferralRow[]): ReferralStatsTotals {
let totalRewardUsd = 0;
let pendingCount = 0;
let convertedCount = 0;
for (const row of referrals) {
if (row.rewardUsd != null && row.rewardUsd > 0) {
totalRewardUsd += row.rewardUsd;
}
if (row.status === 'pending') pendingCount += 1;
if (row.status === 'converted') convertedCount += 1;
}
return { totalRewardUsd, pendingCount, convertedCount };
}
/**
* Map backend `/referral/stats` payload (flexible field names) to UI types.
*/
export function normalizeReferralStats(raw: unknown): ReferralStats {
const r = asRecord(raw) ?? {};
const code = String(r.referralCode ?? r.code ?? '').trim();
const link = String(r.referralLink ?? r.link ?? '').trim();
const totalsRaw = asRecord(r.totals) ?? asRecord(r.summary) ?? {};
const totalFromApi = Math.max(
coerceMoney(
totalsRaw.totalRewardsEarnedUsd ??
totalsRaw.total_rewards_earned_usd ??
totalsRaw.totalRewardUsd ??
totalsRaw.total_reward_usd ??
totalsRaw.lifetimeRewardUsd ??
totalsRaw.lifetime_reward_usd
),
coerceMoney(
r.totalRewardsEarnedUsd ??
r.total_rewards_earned_usd ??
r.totalRewardUsd ??
r.total_reward_usd ??
r.totalEarningsUsd ??
r.total_earnings_usd ??
r.earningsUsd ??
r.earnings_usd ??
r.rewardsTotalUsd ??
r.rewards_total_usd
)
);
const pendingFromApi = Math.round(
num(
totalsRaw.pendingCount ??
totalsRaw.pending_count ??
totalsRaw.pendingReferrals ??
totalsRaw.pending_referrals ??
r.pendingCount ??
r.pending_count
)
);
const convertedFromApi = Math.round(
num(
totalsRaw.convertedCount ??
totalsRaw.converted_count ??
totalsRaw.convertedReferrals ??
totalsRaw.converted_referrals ??
r.convertedCount ??
r.converted_count
)
);
const listRaw = r.referrals ?? r.referralRows ?? r.rows;
const referrals: ReferralRow[] = Array.isArray(listRaw) ? listRaw.map(normalizeRow) : [];
const derived = deriveTotalsFromReferrals(referrals);
const totals: ReferralStatsTotals = {
totalRewardUsd: totalFromApi > 0 ? totalFromApi : derived.totalRewardUsd,
pendingCount: pendingFromApi || derived.pendingCount,
convertedCount: convertedFromApi || derived.convertedCount,
};
const appliedReferralCode =
r.appliedReferralCode === null
? null
: typeof r.appliedReferralCode === 'string'
? r.appliedReferralCode
: typeof r.applied_referral_code === 'string'
? r.applied_referral_code
: undefined;
const canApplyReferral =
typeof r.canApplyReferral === 'boolean'
? r.canApplyReferral
: typeof r.can_apply_referral === 'boolean'
? r.can_apply_referral
: undefined;
const rewardRateBps = num(r.rewardRateBps ?? r.reward_rate_bps);
return {
referralCode: code,
referralLink: link,
totals,
referrals,
appliedReferralCode,
canApplyReferral,
rewardRateBps: rewardRateBps > 0 ? rewardRateBps : undefined,
};
}
export const referralApi = {
/**
* Referral stats via core RPC (`openhuman.referral_get_stats` → backend GET /referral/stats).
* Uses the sidecar HTTP client so the desktop WebView avoids direct `fetch` (fixes WKWebView "Load failed" / CORS to the API host).
*/
getStats: async (): Promise<ReferralStats> => {
try {
const data = await callCoreCommand<unknown>('openhuman.referral_get_stats');
console.debug('[referral] stats loaded via core', {
hasCode: !!(data && typeof data === 'object'),
});
return normalizeReferralStats(data);
} catch (err) {
console.debug('[referral] getStats RPC failed', referralRpcErrorMessage(err));
throwReferralRpcFailure(err);
}
},
/**
* Apply referral code via core RPC (`openhuman.referral_apply` → backend POST /referral/apply).
*/
applyCode: async (code: string): Promise<void> => {
const trimmed = code.trim();
if (!trimmed) {
throw { success: false as const, error: 'Referral code is required' };
}
const deviceFingerprint = getOrCreateDeviceFingerprint();
try {
await callCoreCommand<unknown>('openhuman.referral_apply', {
code: trimmed,
deviceFingerprint,
});
console.debug('[referral] apply succeeded', { codeLength: trimmed.length });
} catch (err) {
console.debug('[referral] apply RPC failed', referralRpcErrorMessage(err));
throwReferralRpcFailure(err);
}
},
};
+36
View File
@@ -0,0 +1,36 @@
/** Normalized referral relationship status for UI (backend: pending | converted; expired reserved). */
export type ReferralRelationshipStatus = 'pending' | 'converted' | 'expired';
export interface ReferralStatsTotals {
/** Total USD credited to the referrer from referral rewards */
totalRewardUsd: number;
pendingCount: number;
convertedCount: number;
}
export interface ReferralRow {
id?: string;
referredUserId?: string;
status: ReferralRelationshipStatus;
referralCode?: string;
createdAt?: string;
convertedAt?: string | null;
/** Reward amount in USD for this relationship when converted */
rewardUsd?: number;
/** Optional display name from backend when user id is hidden */
referredDisplayName?: string;
/** Masked identity from backend (e.g. j***@gmail.com) — preferred for display */
referredUserMasked?: string;
}
export interface ReferralStats {
referralCode: string;
referralLink: string;
totals: ReferralStatsTotals;
referrals: ReferralRow[];
/** Code this user applied as referred (if any) */
appliedReferralCode?: string | null;
/** When false, user likely cannot apply (e.g. already paid); optional from backend */
canApplyReferral?: boolean;
rewardRateBps?: number;
}
+20
View File
@@ -0,0 +1,20 @@
const STORAGE_KEY = 'openhuman_device_fingerprint_v1';
/**
* Stable anonymous id for referral abuse signals (optional body/header on backend).
*/
export function getOrCreateDeviceFingerprint(): string {
try {
let v = localStorage.getItem(STORAGE_KEY);
if (!v) {
v =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `fp_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
localStorage.setItem(STORAGE_KEY, v);
}
return v;
} catch {
return `fp_ephemeral_${Date.now()}`;
}
}
+46 -1
View File
@@ -13,7 +13,8 @@ let mockTunnels = [];
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, x-device-fingerprint",
"Access-Control-Max-Age": "86400",
};
@@ -718,6 +719,50 @@ async function handleRequest(req, res) {
json(res, 200, { success: true, data: { valid: true } });
return;
}
if (method === "GET" && /^\/referral\/stats\/?(\?.*)?$/.test(url)) {
const origin = requestOrigin(req);
json(res, 200, {
success: true,
data: {
referralCode: "MOCKREF1",
referralLink: `${origin}/#/rewards?ref=MOCKREF1`,
rewardRateBps: 2000,
totals: {
totalRewardUsd: 4.2,
pendingCount: 1,
convertedCount: 2,
},
referrals: [
{
id: "ref-row-1",
referredUserId: "user-456",
status: "pending",
createdAt: new Date(Date.now() - 86400000).toISOString(),
},
{
id: "ref-row-2",
referredUserId: "user-789",
status: "converted",
createdAt: new Date(Date.now() - 172800000).toISOString(),
convertedAt: new Date().toISOString(),
rewardUsd: 2.1,
},
],
appliedReferralCode: null,
canApplyReferral: true,
},
});
return;
}
if (method === "POST" && /^\/referral\/apply\/?$/.test(url)) {
json(res, 200, {
success: true,
data: { ok: true, message: "Referral applied" },
});
return;
}
if (
method === "POST" &&
/^\/telegram\/settings\/onboarding-complete\/?$/.test(url)
+3
View File
@@ -66,6 +66,7 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::workspace::all_workspace_registered_controllers());
controllers.extend(crate::openhuman::tools::all_tools_registered_controllers());
controllers.extend(crate::openhuman::memory::all_memory_registered_controllers());
controllers.extend(crate::openhuman::referral::all_referral_registered_controllers());
controllers.extend(crate::openhuman::billing::all_billing_registered_controllers());
controllers.extend(crate::openhuman::team::all_team_registered_controllers());
controllers.extend(crate::openhuman::text_input::all_text_input_registered_controllers());
@@ -106,6 +107,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::workspace::all_workspace_controller_schemas());
schemas.extend(crate::openhuman::tools::all_tools_controller_schemas());
schemas.extend(crate::openhuman::memory::all_memory_controller_schemas());
schemas.extend(crate::openhuman::referral::all_referral_controller_schemas());
schemas.extend(crate::openhuman::billing::all_billing_controller_schemas());
schemas.extend(crate::openhuman::team::all_team_controller_schemas());
schemas.extend(crate::openhuman::text_input::all_text_input_controller_schemas());
@@ -150,6 +152,7 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"skills" => Some("Skill registry, runtime lifecycle, setup, tools, and sync."),
"socket" => Some("Skills runtime socket bridge controls."),
"memory" => Some("Document storage, vector search, key-value store, and knowledge graph."),
"referral" => Some("Referral codes, stats, and apply flows via the hosted backend API."),
"billing" => Some("Subscription plan, payment links, and credit top-up via the backend."),
"team" => Some("Team member management, invites, and role changes via the backend."),
"voice" => Some("Speech-to-text and text-to-speech using local models."),
+1
View File
@@ -39,6 +39,7 @@ pub mod memory;
pub mod migration;
pub mod overlay;
pub mod providers;
pub mod referral;
pub mod screen_intelligence;
pub mod security;
pub mod service;
+9
View File
@@ -0,0 +1,9 @@
//! Referral program RPC adapters (hosted API).
mod ops;
mod schemas;
pub use ops::*;
pub use schemas::{
all_referral_controller_schemas, all_referral_registered_controllers, referral_schemas,
};
+71
View File
@@ -0,0 +1,71 @@
//! Referral program — authenticated calls to the hosted API (`/referral/*`).
//!
//! The desktop WebView `fetch` to the backend can fail with a generic "Load failed"
//! (CORS / TLS / WebKit). These ops reuse the same `reqwest` path as billing.
use reqwest::Method;
use serde_json::{json, Map, Value};
use crate::api::config::effective_api_url;
use crate::api::jwt::get_session_token;
use crate::api::BackendOAuthClient;
use crate::openhuman::config::Config;
use crate::rpc::RpcOutcome;
fn require_token(config: &Config) -> Result<String, String> {
get_session_token(config)?
.and_then(|v| {
let t = v.trim().to_string();
if t.is_empty() {
None
} else {
Some(t)
}
})
.ok_or_else(|| "no backend session token; run auth_store_session first".to_string())
}
pub async fn get_stats(config: &Config) -> Result<RpcOutcome<Value>, String> {
let token = require_token(config)?;
let api_url = effective_api_url(&config.api_url);
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
let data = client
.authed_json(&token, Method::GET, "/referral/stats", None)
.await
.map_err(|e| e.to_string())?;
Ok(RpcOutcome::single_log(
data,
"referral stats fetched from backend GET /referral/stats",
))
}
pub async fn apply_code(
config: &Config,
code: &str,
device_fingerprint: Option<&str>,
) -> Result<RpcOutcome<Value>, String> {
let token = require_token(config)?;
let api_url = effective_api_url(&config.api_url);
let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?;
let mut body = Map::new();
body.insert("code".to_string(), json!(code.trim()));
if let Some(fp) = device_fingerprint.map(str::trim).filter(|s| !s.is_empty()) {
body.insert("deviceFingerprint".to_string(), json!(fp));
}
let data = client
.authed_json(
&token,
Method::POST,
"/referral/apply",
Some(Value::Object(body)),
)
.await
.map_err(|e| e.to_string())?;
Ok(RpcOutcome::single_log(
data,
"referral apply accepted by backend POST /referral/apply",
))
}
+125
View File
@@ -0,0 +1,125 @@
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReferralApplyParams {
code: String,
#[serde(default)]
device_fingerprint: Option<String>,
}
pub fn all_referral_controller_schemas() -> Vec<ControllerSchema> {
vec![
referral_schemas("referral_get_stats"),
referral_schemas("referral_apply"),
]
}
pub fn all_referral_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: referral_schemas("referral_get_stats"),
handler: handle_referral_get_stats,
},
RegisteredController {
schema: referral_schemas("referral_apply"),
handler: handle_referral_apply,
},
]
}
pub fn referral_schemas(function: &str) -> ControllerSchema {
match function {
"referral_get_stats" => ControllerSchema {
namespace: "referral",
function: "get_stats",
description:
"Fetch referral code, link, totals, and referred-user rows from the backend.",
inputs: vec![],
outputs: vec![json_output(
"stats",
"Payload from GET /referral/stats (backend `data` field).",
)],
},
"referral_apply" => ControllerSchema {
namespace: "referral",
function: "apply",
description:
"Apply a friend's referral code for the current user (backend eligibility rules).",
inputs: vec![
FieldSchema {
name: "code",
ty: TypeSchema::String,
comment: "Referral code to apply.",
required: true,
},
FieldSchema {
name: "deviceFingerprint",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional client fingerprint for abuse signals.",
required: false,
},
],
outputs: vec![json_output(
"result",
"Payload from POST /referral/apply (backend `data` field).",
)],
},
_ => ControllerSchema {
namespace: "referral",
function: "unknown",
description: "Unknown referral controller.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_referral_get_stats(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::referral::get_stats(&config).await?)
})
}
fn handle_referral_apply(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<ReferralApplyParams>(params)?;
let fp = payload
.device_fingerprint
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
to_json(crate::openhuman::referral::apply_code(&config, payload.code.trim(), fp).await?)
})
}
fn to_json(outcome: RpcOutcome<Value>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
}
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Json,
comment,
required: true,
}
}