diff --git a/services/platform-service/src/modules/marketplace/creator-program.test.ts b/services/platform-service/src/modules/marketplace/creator-program.test.ts new file mode 100644 index 00000000..1ad493fe --- /dev/null +++ b/services/platform-service/src/modules/marketplace/creator-program.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect } from 'vitest'; +import { + buildCreatorApplication, + approveCreator, + rejectCreator, + buildVerifiedCreator, + buildAgentPack, + publishPack, + unpublishPack, + validatePack, +} from './creator-program.js'; + +// ── Creator Application ───────────────────────────────────── + +describe('buildCreatorApplication', () => { + const base = { + userId: 'user_1', + displayName: 'Dr. Sarah Coach', + email: 'sarah@coaching.com', + bio: 'ICF-certified executive coach with 10 years experience', + credentials: ['ICF PCC', 'MBA'], + portfolio: ['https://sarahcoach.com'], + }; + + it('creates application with unique id', () => { + const a = buildCreatorApplication(base); + const b = buildCreatorApplication(base); + expect(a.id).not.toBe(b.id); + expect(a.id).toMatch(/^creator_app_/); + }); + + it('sets status to pending', () => { + const app = buildCreatorApplication(base); + expect(app.status).toBe('pending'); + expect(app.reviewedBy).toBeNull(); + }); + + it('includes productId jarvisjr', () => { + const app = buildCreatorApplication(base); + expect(app.productId).toBe('jarvisjr'); + }); +}); + +describe('approveCreator', () => { + it('marks application as approved', () => { + const app = buildCreatorApplication({ + userId: 'u1', + displayName: 'Test', + email: 'a@b.com', + bio: 'Coach', + credentials: [], + portfolio: [], + }); + const approved = approveCreator(app, 'admin_1', 'Great credentials'); + expect(approved.status).toBe('approved'); + expect(approved.reviewedBy).toBe('admin_1'); + expect(approved.reviewNote).toBe('Great credentials'); + expect(approved.reviewedAt).toBeTruthy(); + }); +}); + +describe('rejectCreator', () => { + it('marks application as rejected with reason', () => { + const app = buildCreatorApplication({ + userId: 'u1', + displayName: 'Test', + email: 'a@b.com', + bio: 'X', + credentials: [], + portfolio: [], + }); + const rejected = rejectCreator(app, 'admin_1', 'Insufficient credentials'); + expect(rejected.status).toBe('rejected'); + expect(rejected.reviewNote).toBe('Insufficient credentials'); + }); +}); + +// ── Verified Creator ──────────────────────────────────────── + +describe('buildVerifiedCreator', () => { + it('creates verified profile from approved application', () => { + const app = buildCreatorApplication({ + userId: 'u1', + displayName: 'Coach Pro', + email: 'a@b.com', + bio: 'Expert coach', + credentials: ['ICF PCC'], + portfolio: [], + }); + const approved = approveCreator(app, 'admin', 'OK'); + const creator = buildVerifiedCreator(approved); + expect(creator.id).toMatch(/^creator_/); + expect(creator.badges).toContain('verified'); + expect(creator.totalSales).toBe(0); + expect(creator.productId).toBe('jarvisjr'); + }); + + it('accepts custom badges', () => { + const app = buildCreatorApplication({ + userId: 'u1', + displayName: 'Dr. Therapy', + email: 'a@b.com', + bio: 'Licensed therapist', + credentials: ['PhD Psychology'], + portfolio: [], + }); + const creator = buildVerifiedCreator(app, ['verified', 'certified_therapist']); + expect(creator.badges).toEqual(['verified', 'certified_therapist']); + }); +}); + +// ── Agent Packs ───────────────────────────────────────────── + +describe('buildAgentPack', () => { + it('creates pack with discount applied', () => { + const pack = buildAgentPack({ + creatorId: 'creator_1', + title: 'Career Accelerator Pack', + description: 'Three agents to boost your career: Coach, Mentor, Orator', + category: 'career', + agentListingIds: ['l1', 'l2', 'l3'], + individualPrices: [4.99, 4.99, 4.99], + discountPercent: 20, + }); + expect(pack.individualTotalUsd).toBeCloseTo(14.97, 2); + expect(pack.priceUsd).toBeCloseTo(11.98, 2); + expect(pack.discountPercent).toBe(20); + expect(pack.isPublished).toBe(false); + expect(pack.productId).toBe('jarvisjr'); + }); + + it('generates unique id', () => { + const a = buildAgentPack({ + creatorId: 'c1', + title: 'Pack A', + description: 'Test pack description', + category: 'language', + agentListingIds: ['l1', 'l2'], + individualPrices: [2.99, 2.99], + discountPercent: 10, + }); + const b = buildAgentPack({ + creatorId: 'c1', + title: 'Pack B', + description: 'Another test pack', + category: 'creativity', + agentListingIds: ['l3', 'l4'], + individualPrices: [3.99, 3.99], + discountPercent: 15, + }); + expect(a.id).not.toBe(b.id); + }); +}); + +describe('publishPack', () => { + it('sets isPublished to true', () => { + const pack = buildAgentPack({ + creatorId: 'c1', + title: 'Test Pack', + description: 'A test pack for publishing', + category: 'wellness', + agentListingIds: ['l1', 'l2', 'l3'], + individualPrices: [4.99, 4.99, 4.99], + discountPercent: 15, + }); + const published = publishPack(pack); + expect(published.isPublished).toBe(true); + }); +}); + +describe('unpublishPack', () => { + it('sets isPublished to false', () => { + const pack = publishPack( + buildAgentPack({ + creatorId: 'c1', + title: 'Test Pack', + description: 'A test pack for unpublishing', + category: 'leadership', + agentListingIds: ['l1', 'l2'], + individualPrices: [4.99, 4.99], + discountPercent: 10, + }) + ); + const unpublished = unpublishPack(pack); + expect(unpublished.isPublished).toBe(false); + }); +}); + +// ── Pack Validation ───────────────────────────────────────── + +describe('validatePack', () => { + function makePack(overrides: Record = {}) { + return buildAgentPack({ + creatorId: 'c1', + title: 'Career Accelerator Pack', + description: 'A great pack of career coaching agents for professionals', + category: 'career', + agentListingIds: ['l1', 'l2', 'l3'], + individualPrices: [4.99, 4.99, 4.99], + discountPercent: 20, + ...overrides, + }); + } + + it('valid pack passes', () => { + const result = validatePack(makePack()); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects pack with fewer than 2 agents', () => { + const pack = makePack({ agentListingIds: ['l1'], individualPrices: [4.99] }); + const result = validatePack(pack); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Pack must contain at least 2 agents'); + }); + + it('rejects pack with more than 10 agents', () => { + const ids = Array.from({ length: 11 }, (_, i) => `l${i}`); + const prices = ids.map(() => 4.99); + const pack = makePack({ agentListingIds: ids, individualPrices: prices }); + const result = validatePack(pack); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Pack cannot contain more than 10 agents'); + }); + + it('rejects short title', () => { + const pack = makePack({ title: 'AB' }); + const result = validatePack(pack); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Title must be at least 3 characters'); + }); + + it('rejects discount below 5%', () => { + const pack = makePack({ discountPercent: 2 }); + const result = validatePack(pack); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Pack discount must be at least 5%'); + }); + + it('rejects discount above 50%', () => { + const pack = makePack({ discountPercent: 60 }); + const result = validatePack(pack); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Pack discount cannot exceed 50%'); + }); +}); diff --git a/services/platform-service/src/modules/marketplace/creator-program.ts b/services/platform-service/src/modules/marketplace/creator-program.ts new file mode 100644 index 00000000..435e5c97 --- /dev/null +++ b/services/platform-service/src/modules/marketplace/creator-program.ts @@ -0,0 +1,233 @@ +/** + * Verified Creator Program — application, review, and badge management. + * Supports professional agent pack bundles with revenue share. + */ + +import crypto from 'node:crypto'; + +// ── Creator Application ───────────────────────────────────── + +export interface CreatorApplication { + id: string; + productId: string; + userId: string; + displayName: string; + email: string; + bio: string; + credentials: string[]; + portfolio: string[]; + status: 'pending' | 'approved' | 'rejected'; + reviewedBy: string | null; + reviewNote: string | null; + appliedAt: string; + reviewedAt: string | null; +} + +export function buildCreatorApplication(input: { + userId: string; + displayName: string; + email: string; + bio: string; + credentials: string[]; + portfolio: string[]; +}): CreatorApplication { + return { + id: `creator_app_${crypto.randomUUID()}`, + productId: 'jarvisjr', + userId: input.userId, + displayName: input.displayName, + email: input.email, + bio: input.bio, + credentials: input.credentials, + portfolio: input.portfolio, + status: 'pending', + reviewedBy: null, + reviewNote: null, + appliedAt: new Date().toISOString(), + reviewedAt: null, + }; +} + +export function approveCreator( + app: CreatorApplication, + reviewerId: string, + note: string +): CreatorApplication { + return { + ...app, + status: 'approved', + reviewedBy: reviewerId, + reviewNote: note, + reviewedAt: new Date().toISOString(), + }; +} + +export function rejectCreator( + app: CreatorApplication, + reviewerId: string, + note: string +): CreatorApplication { + return { + ...app, + status: 'rejected', + reviewedBy: reviewerId, + reviewNote: note, + reviewedAt: new Date().toISOString(), + }; +} + +// ── Verified Creator Profile ──────────────────────────────── + +export interface VerifiedCreator { + id: string; + productId: string; + userId: string; + displayName: string; + bio: string; + credentials: string[]; + badges: CreatorBadge[]; + totalListings: number; + totalSales: number; + totalEarningsUsd: number; + rating: number; + verifiedAt: string; +} + +export type CreatorBadge = + | 'verified' + | 'top_seller' + | 'certified_coach' + | 'certified_therapist' + | 'expert_linguist' + | 'community_choice'; + +export function buildVerifiedCreator( + app: CreatorApplication, + badges: CreatorBadge[] = ['verified'] +): VerifiedCreator { + return { + id: `creator_${crypto.randomUUID()}`, + productId: 'jarvisjr', + userId: app.userId, + displayName: app.displayName, + bio: app.bio, + credentials: app.credentials, + badges, + totalListings: 0, + totalSales: 0, + totalEarningsUsd: 0, + rating: 0, + verifiedAt: new Date().toISOString(), + }; +} + +// ── Pack Bundles ──────────────────────────────────────────── + +export interface AgentPack { + id: string; + productId: string; + creatorId: string; + title: string; + description: string; + category: PackCategory; + agentListingIds: string[]; + priceUsd: number; + discountPercent: number; + individualTotalUsd: number; + isPublished: boolean; + createdAt: string; + updatedAt: string; +} + +export type PackCategory = + | 'career' + | 'language' + | 'creativity' + | 'wellness' + | 'leadership' + | 'communication' + | 'custom'; + +export function buildAgentPack(input: { + creatorId: string; + title: string; + description: string; + category: PackCategory; + agentListingIds: string[]; + individualPrices: number[]; + discountPercent: number; +}): AgentPack { + const individualTotal = input.individualPrices.reduce((a, b) => a + b, 0); + const discounted = individualTotal * (1 - input.discountPercent / 100); + const now = new Date().toISOString(); + + return { + id: `pack_${crypto.randomUUID()}`, + productId: 'jarvisjr', + creatorId: input.creatorId, + title: input.title, + description: input.description, + category: input.category, + agentListingIds: input.agentListingIds, + priceUsd: Math.round(discounted * 100) / 100, + discountPercent: input.discountPercent, + individualTotalUsd: Math.round(individualTotal * 100) / 100, + isPublished: false, + createdAt: now, + updatedAt: now, + }; +} + +export function publishPack(pack: AgentPack): AgentPack { + return { + ...pack, + isPublished: true, + updatedAt: new Date().toISOString(), + }; +} + +export function unpublishPack(pack: AgentPack): AgentPack { + return { + ...pack, + isPublished: false, + updatedAt: new Date().toISOString(), + }; +} + +// ── Pack Validation ───────────────────────────────────────── + +export interface PackValidationResult { + valid: boolean; + errors: string[]; +} + +export function validatePack(pack: AgentPack): PackValidationResult { + const errors: string[] = []; + + if (pack.agentListingIds.length < 2) { + errors.push('Pack must contain at least 2 agents'); + } + if (pack.agentListingIds.length > 10) { + errors.push('Pack cannot contain more than 10 agents'); + } + if (pack.title.length < 3) { + errors.push('Title must be at least 3 characters'); + } + if (pack.title.length > 80) { + errors.push('Title must be at most 80 characters'); + } + if (pack.description.length < 10) { + errors.push('Description must be at least 10 characters'); + } + if (pack.discountPercent < 5) { + errors.push('Pack discount must be at least 5%'); + } + if (pack.discountPercent > 50) { + errors.push('Pack discount cannot exceed 50%'); + } + if (pack.priceUsd <= 0) { + errors.push('Pack price must be positive'); + } + + return { valid: errors.length === 0, errors }; +}