From e963d9cab03cd9b7a50a33679e4cbf4600cf0830 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 29 Sep 2026 15:32:00 +0300 Subject: [PATCH] feat(referral): add polymorfa referral link Prints the team's referral link from GET /platform/referral; --json prints the full response. Reads through the SDK raw client until the pinned SDK publishes Client.referral.retrieve (polymorfa/sdks#325). --- README.md | 16 +++++ package.json | 3 + src/commands/referral/link.ts | 20 ++++++ src/services/referral.ts | 109 ++++++++++++++++++++++++++++++++ test/commands/referral.test.ts | 112 +++++++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+) create mode 100644 src/commands/referral/link.ts create mode 100644 src/services/referral.ts create mode 100644 test/commands/referral.test.ts diff --git a/README.md b/README.md index e2755db..3edafc0 100644 --- a/README.md +++ b/README.md @@ -1427,6 +1427,22 @@ best effort. Neither operation returns an operation ID or supports status, watch, stream, cancellation, history, or per-item results. Retrying stop can enqueue another request, while repeated deletion counts only rows still present. +### Referral link + +`referral link` prints your team's referral link. It reads +`GET /platform/referral` with a team API key that has `sessions:read`; the API +creates the team's shared link on the first read. + +```shell +polymorfa referral link +polymorfa referral link --json +``` + +`--json` also returns the referral code, whether the program accepts new +referrals (`enabled`), the welcome bonus, reward tiers, the per-referral +maximum, the waiting period in days, and whether group sharing is on. Credit +amounts are decimal strings. A team without referral access exits with code 3. + ## Messaging campaigns Campaign commands use organization Messaging API keys and require an explicit diff --git a/package.json b/package.json index 03953d3..987ada7 100644 --- a/package.json +++ b/package.json @@ -234,6 +234,9 @@ "quicklink": { "description": "Create, inspect, and cancel persistent onboarding QuickLinks." }, + "referral": { + "description": "Read your team's referral link." + }, "security-incident": { "description": "Inspect and acknowledge organization security incidents." }, diff --git a/src/commands/referral/link.ts b/src/commands/referral/link.ts new file mode 100644 index 0000000..5dd2578 --- /dev/null +++ b/src/commands/referral/link.ts @@ -0,0 +1,20 @@ +import {BaseCommand} from '../../base-command.js' +import {getReferral, renderReferralLink} from '../../services/referral.js' + +export default class ReferralLink extends BaseCommand { + static override description = + 'A team created from the link gets extra credits on its first top-up, and your team earns bonus credits. Use --json for the code and the current program terms. Requires a team API key with sessions:read.' + + static override examples = [ + '<%= config.bin %> referral link', + '<%= config.bin %> referral link --json', + ] + + static override summary = "Print your team's referral link." + + async run(): Promise { + const {flags} = await this.parse(ReferralLink) + const result = await getReferral(await this.platformClient(flags)) + this.writeValue(result, renderReferralLink(result.data), flags) + } +} diff --git a/src/services/referral.ts b/src/services/referral.ts new file mode 100644 index 0000000..eccd98f --- /dev/null +++ b/src/services/referral.ts @@ -0,0 +1,109 @@ +import { + type ClientInstance, + PolymorfaServerError, + type ResponseMetadata, +} from '@polymorfa/sdk' + +import {terminalText} from '../developer/render.js' +import type {PlatformResult} from './platform-management.js' + +// `GET /platform/referral`. The pinned SDK does not yet have a typed +// `client.referral.retrieve()`; until it is published (polymorfa/sdks +// `t3code/referrals`), this reads the route through the SDK's raw client and +// validates the response shape here. Replace with the typed method once the +// CLI pins an SDK build that includes it. + +export interface ReferralWelcomeBonus { + readonly percent: number + /** Decimal string, such as `"5000"`. */ + readonly capCredits: string +} + +export interface ReferralTier { + readonly minReferrals: number + readonly percent: number +} + +export interface Referral { + readonly code: string + readonly link: string + readonly enabled: boolean + readonly welcomeBonus: ReferralWelcomeBonus + readonly tiers: readonly ReferralTier[] + /** Decimal string, such as `"10000"`. */ + readonly rewardCapCredits: string + readonly holdDays: number + readonly groupShareEnabled: boolean +} + +export async function getReferral( + client: ClientInstance<'organization'>, +): Promise> { + const response = await client.raw.request({ + method: 'GET', + path: '/platform/referral', + }) + const data = isRecord(response.data) ? response.data.data : undefined + if (!isReferral(data)) throw invalidResponse(response.data, response.metadata) + return { + data, + metadata: { + attempts: response.metadata.attempts, + status: response.metadata.status, + ...(response.metadata.apiVersion === undefined + ? {} + : {apiVersion: response.metadata.apiVersion}), + ...(response.metadata.requestId === undefined + ? {} + : {requestId: response.metadata.requestId}), + }, + } +} + +export function renderReferralLink(referral: Referral): string { + return terminalText(referral.link) +} + +function isReferral(value: unknown): value is Referral { + return ( + isRecord(value) && + typeof value.code === 'string' && + typeof value.link === 'string' && + typeof value.enabled === 'boolean' && + isRecord(value.welcomeBonus) && + typeof value.welcomeBonus.percent === 'number' && + typeof value.welcomeBonus.capCredits === 'string' && + Array.isArray(value.tiers) && + value.tiers.every( + (tier) => + isRecord(tier) && + typeof tier.minReferrals === 'number' && + typeof tier.percent === 'number', + ) && + typeof value.rewardCapCredits === 'string' && + typeof value.holdDays === 'number' && + typeof value.groupShareEnabled === 'boolean' + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function invalidResponse( + details: unknown, + metadata: ResponseMetadata, +): PolymorfaServerError { + return new PolymorfaServerError( + 'The Polymorfa API returned an invalid referral response.', + { + code: 'invalid_response', + details, + metadata, + status: metadata.status, + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + ) +} diff --git a/test/commands/referral.test.ts b/test/commands/referral.test.ts new file mode 100644 index 0000000..757d89e --- /dev/null +++ b/test/commands/referral.test.ts @@ -0,0 +1,112 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const REFERRAL = { + code: 'k3m9p2q8rt', + link: 'https://polymorfa.com/r/k3m9p2q8rt', + enabled: true, + welcomeBonus: {percent: 25, capCredits: '5000'}, + tiers: [ + {minReferrals: 1, percent: 10}, + {minReferrals: 4, percent: 15}, + {minReferrals: 25, percent: 20}, + ], + rewardCapCredits: '10000', + holdDays: 30, + groupShareEnabled: true, +} + +describe('referral link', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('prints only the link', async () => { + server = await startHttpServer({ + body: {data: REFERRAL}, + headers: {'request-id': 'req_referral'}, + }) + + const result = await platformEnvironment(() => runCommand('referral link')) + + expect(result.error).toBeUndefined() + expect(result.stdout.trim()).toBe('https://polymorfa.com/r/k3m9p2q8rt') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/referral', + ]) + expect(server.requests[0]?.headers.authorization).toMatch(/^Bearer pmfa_/) + }) + + test('prints the full response with --json', async () => { + server = await startHttpServer({ + body: {data: REFERRAL}, + headers: {'request-id': 'req_referral'}, + }) + + const result = await platformEnvironment(() => + runCommand('referral link --json'), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({ + data: REFERRAL, + metadata: {requestId: 'req_referral', status: 200}, + }) + }) + + test('reports a team without referral access as a failure', async () => { + server = await startHttpServer({ + status: 403, + body: { + error: { + type: 'permission_error', + code: 'feature_unavailable', + message: 'Referrals are not available to this team.', + param: null, + request_id: 'req_denied', + }, + data: null, + }, + }) + + const result = await platformEnvironment(() => runCommand('referral link')) + + expect(exitCode(result.error)).toBe(3) + expect(result.stdout).toBe('') + }) + + test('rejects a response without a referral link', async () => { + server = await startHttpServer({body: {data: {code: 'k3m9p2q8rt'}}}) + + const result = await platformEnvironment(() => runCommand('referral link')) + + expect(exitCode(result.error)).toBe(5) + expect(result.stdout).toBe('') + }) + + async function platformEnvironment(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } +}) + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +}