feat(marketplace): verified creator program — applications, badges, agent pack bundles with discount validation (17 tests)
This commit is contained in:
parent
66d6aa7b5b
commit
5088507400
@ -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<string, unknown> = {}) {
|
||||
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%');
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user