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