From 761a0c17f455374113a78397a4d2e78f8e1eaeb0 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 1 Mar 2026 08:01:45 -0800 Subject: [PATCH] =?UTF-8?q?feat(platform):=20marketplace=20module=20?= =?UTF-8?q?=E2=80=94=20types,=20repository,=2052=20tests=20passing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/marketplace/marketplace.test.ts | 415 ++++++++++++++++++ .../src/modules/marketplace/repository.ts | 194 ++++++++ .../src/modules/marketplace/types.ts | 167 +++++++ 3 files changed, 776 insertions(+) create mode 100644 services/platform-service/src/modules/marketplace/marketplace.test.ts create mode 100644 services/platform-service/src/modules/marketplace/repository.ts create mode 100644 services/platform-service/src/modules/marketplace/types.ts diff --git a/services/platform-service/src/modules/marketplace/marketplace.test.ts b/services/platform-service/src/modules/marketplace/marketplace.test.ts new file mode 100644 index 00000000..be346122 --- /dev/null +++ b/services/platform-service/src/modules/marketplace/marketplace.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect } from 'vitest'; +import { + LISTING_STATUSES, + LISTING_CATEGORIES, + CERTIFICATION_STATUSES, + REPORT_STATUSES, + REPORT_REASONS, + CreateListingSchema, + UpdateListingSchema, + CreateReviewSchema, + CreateReportSchema, + ResolveReportSchema, + CertificationDecisionSchema, + BrowseCatalogQuerySchema, + ListQuerySchema, +} from './types.js'; +import { + buildListingDoc, + applyListingUpdate, + buildReviewDoc, + buildInstallDoc, + buildCertificationDoc, + buildReportDoc, + buildCatalogSortClause, + buildCatalogWhereClause, + buildListWhereClause, +} from './repository.js'; + +// ── Constants ─────────────────────────────────────────────── + +describe('marketplace constants', () => { + it('has expected listing statuses', () => { + expect(LISTING_STATUSES).toEqual(['draft', 'pending', 'published', 'suspended']); + }); + + it('has expected listing categories', () => { + expect(LISTING_CATEGORIES.length).toBe(9); + expect(LISTING_CATEGORIES).toContain('coaching'); + expect(LISTING_CATEGORIES).toContain('other'); + }); + + it('has expected certification statuses', () => { + expect(CERTIFICATION_STATUSES).toEqual(['pending', 'approved', 'rejected']); + }); + + it('has expected report statuses', () => { + expect(REPORT_STATUSES).toEqual(['open', 'investigating', 'resolved', 'dismissed']); + }); + + it('has expected report reasons', () => { + expect(REPORT_REASONS.length).toBe(6); + expect(REPORT_REASONS).toContain('inappropriate_content'); + expect(REPORT_REASONS).toContain('safety_concern'); + }); +}); + +// ── CreateListingSchema ───────────────────────────────────── + +describe('CreateListingSchema', () => { + const validInput = { + title: 'Test Agent', + description: 'A test coaching agent.', + category: 'coaching' as const, + agentConfig: { name: 'Test', role: 'Tester' }, + }; + + it('accepts minimal valid input with defaults', () => { + const result = CreateListingSchema.parse(validInput); + expect(result.title).toBe('Test Agent'); + expect(result.tags).toEqual([]); + expect(result.version).toBe('1.0.0'); + }); + + it('accepts full input with all fields', () => { + const result = CreateListingSchema.parse({ + ...validInput, + tags: ['coaching', 'leadership'], + version: '2.0.0', + }); + expect(result.tags).toEqual(['coaching', 'leadership']); + expect(result.version).toBe('2.0.0'); + }); + + it('rejects missing title', () => { + expect(() => CreateListingSchema.parse({ ...validInput, title: undefined })).toThrow(); + }); + + it('rejects missing description', () => { + expect(() => CreateListingSchema.parse({ ...validInput, description: undefined })).toThrow(); + }); + + it('rejects missing category', () => { + expect(() => CreateListingSchema.parse({ ...validInput, category: undefined })).toThrow(); + }); + + it('rejects invalid category', () => { + expect(() => CreateListingSchema.parse({ ...validInput, category: 'invalid' })).toThrow(); + }); + + it('rejects missing agentConfig', () => { + expect(() => CreateListingSchema.parse({ ...validInput, agentConfig: undefined })).toThrow(); + }); + + it('rejects title exceeding max length', () => { + expect(() => CreateListingSchema.parse({ ...validInput, title: 'x'.repeat(201) })).toThrow(); + }); + + it('rejects too many tags', () => { + const tags = Array.from({ length: 11 }, (_, i) => `tag${i}`); + expect(() => CreateListingSchema.parse({ ...validInput, tags })).toThrow(); + }); +}); + +// ── UpdateListingSchema ───────────────────────────────────── + +describe('UpdateListingSchema', () => { + it('accepts partial update with single field', () => { + const result = UpdateListingSchema.parse({ title: 'Updated' }); + expect(result.title).toBe('Updated'); + expect(result.description).toBeUndefined(); + }); + + it('accepts empty update (no fields)', () => { + const result = UpdateListingSchema.parse({}); + expect(Object.keys(result)).toHaveLength(0); + }); + + it('rejects invalid category in update', () => { + expect(() => UpdateListingSchema.parse({ category: 'invalid' })).toThrow(); + }); +}); + +// ── CreateReviewSchema ────────────────────────────────────── + +describe('CreateReviewSchema', () => { + it('accepts valid review', () => { + const result = CreateReviewSchema.parse({ rating: 5, comment: 'Great agent!' }); + expect(result.rating).toBe(5); + expect(result.comment).toBe('Great agent!'); + }); + + it('rejects rating below 1', () => { + expect(() => CreateReviewSchema.parse({ rating: 0, comment: 'Bad' })).toThrow(); + }); + + it('rejects rating above 5', () => { + expect(() => CreateReviewSchema.parse({ rating: 6, comment: 'Too high' })).toThrow(); + }); + + it('rejects empty comment', () => { + expect(() => CreateReviewSchema.parse({ rating: 3, comment: '' })).toThrow(); + }); + + it('rejects comment exceeding max length', () => { + expect(() => CreateReviewSchema.parse({ rating: 3, comment: 'x'.repeat(2001) })).toThrow(); + }); +}); + +// ── CreateReportSchema ────────────────────────────────────── + +describe('CreateReportSchema', () => { + it('accepts valid report', () => { + const result = CreateReportSchema.parse({ + reason: 'spam', + details: 'This listing is spam.', + }); + expect(result.reason).toBe('spam'); + }); + + it('rejects invalid reason', () => { + expect(() => CreateReportSchema.parse({ reason: 'invalid', details: 'test' })).toThrow(); + }); + + it('rejects empty details', () => { + expect(() => CreateReportSchema.parse({ reason: 'spam', details: '' })).toThrow(); + }); +}); + +// ── ResolveReportSchema ───────────────────────────────────── + +describe('ResolveReportSchema', () => { + it('accepts valid resolution', () => { + const result = ResolveReportSchema.parse({ resolution: 'Listing suspended.' }); + expect(result.resolution).toBe('Listing suspended.'); + }); + + it('rejects empty resolution', () => { + expect(() => ResolveReportSchema.parse({ resolution: '' })).toThrow(); + }); +}); + +// ── CertificationDecisionSchema ───────────────────────────── + +describe('CertificationDecisionSchema', () => { + it('accepts with notes', () => { + const result = CertificationDecisionSchema.parse({ notes: 'Looks good.' }); + expect(result.notes).toBe('Looks good.'); + }); + + it('defaults notes to empty string', () => { + const result = CertificationDecisionSchema.parse({}); + expect(result.notes).toBe(''); + }); +}); + +// ── BrowseCatalogQuerySchema ──────────────────────────────── + +describe('BrowseCatalogQuerySchema', () => { + it('applies defaults for empty query', () => { + const result = BrowseCatalogQuerySchema.parse({}); + expect(result.sort).toBe('popular'); + expect(result.limit).toBe(20); + expect(result.offset).toBe(0); + }); + + it('accepts category filter', () => { + const result = BrowseCatalogQuerySchema.parse({ category: 'coaching' }); + expect(result.category).toBe('coaching'); + }); + + it('accepts search term', () => { + const result = BrowseCatalogQuerySchema.parse({ search: 'interview' }); + expect(result.search).toBe('interview'); + }); + + it('rejects invalid sort', () => { + expect(() => BrowseCatalogQuerySchema.parse({ sort: 'invalid' })).toThrow(); + }); + + it('rejects limit exceeding max', () => { + expect(() => BrowseCatalogQuerySchema.parse({ limit: 101 })).toThrow(); + }); +}); + +// ── ListQuerySchema ───────────────────────────────────────── + +describe('ListQuerySchema', () => { + it('applies defaults for empty query', () => { + const result = ListQuerySchema.parse({}); + expect(result.limit).toBe(50); + expect(result.offset).toBe(0); + }); + + it('accepts status filter', () => { + const result = ListQuerySchema.parse({ status: 'pending' }); + expect(result.status).toBe('pending'); + }); + + it('rejects invalid status', () => { + expect(() => ListQuerySchema.parse({ status: 'invalid' })).toThrow(); + }); +}); + +// ── Repository Builders ───────────────────────────────────── + +describe('buildListingDoc', () => { + it('creates a listing with correct fields', () => { + const doc = buildListingDoc( + { + title: 'Test', + description: 'Desc', + category: 'coaching', + agentConfig: { name: 'Test' }, + tags: ['tag1'], + version: '1.0.0', + }, + 'user_1', + 'jarvisjr' + ); + expect(doc.id).toMatch(/^listing_/); + expect(doc.authorId).toBe('user_1'); + expect(doc.productId).toBe('jarvisjr'); + expect(doc.status).toBe('draft'); + expect(doc.downloads).toBe(0); + expect(doc.rating).toBe(0); + expect(doc.isFeatured).toBe(false); + expect(doc.isVerified).toBe(false); + }); +}); + +describe('applyListingUpdate', () => { + it('updates specified fields only', () => { + const original = buildListingDoc( + { + title: 'Original', + description: 'Desc', + category: 'coaching', + agentConfig: {}, + tags: [], + version: '1.0.0', + }, + 'user_1', + 'jarvisjr' + ); + const updated = applyListingUpdate(original, { title: 'Updated' }); + expect(updated.title).toBe('Updated'); + expect(updated.description).toBe('Desc'); + expect(updated.updatedAt).toBeTruthy(); + expect(new Date(updated.updatedAt).toISOString()).toBe(updated.updatedAt); + }); +}); + +describe('buildReviewDoc', () => { + it('creates a review with correct fields', () => { + const doc = buildReviewDoc({ rating: 4, comment: 'Good' }, 'listing_1', 'user_1', 'jarvisjr'); + expect(doc.id).toMatch(/^review_/); + expect(doc.listingId).toBe('listing_1'); + expect(doc.rating).toBe(4); + }); +}); + +describe('buildInstallDoc', () => { + it('creates an install with correct fields', () => { + const doc = buildInstallDoc('listing_1', 'user_1', 'jarvisjr'); + expect(doc.id).toMatch(/^install_/); + expect(doc.listingId).toBe('listing_1'); + expect(doc.userId).toBe('user_1'); + }); +}); + +describe('buildCertificationDoc', () => { + it('creates a certification with pending status', () => { + const doc = buildCertificationDoc('listing_1', 'jarvisjr'); + expect(doc.id).toMatch(/^cert_/); + expect(doc.status).toBe('pending'); + expect(doc.reviewerId).toBeNull(); + }); +}); + +describe('buildReportDoc', () => { + it('creates a report with open status', () => { + const doc = buildReportDoc( + { reason: 'spam', details: 'This is spam.' }, + 'listing_1', + 'user_1', + 'jarvisjr' + ); + expect(doc.id).toMatch(/^report_/); + expect(doc.status).toBe('open'); + expect(doc.resolvedBy).toBeNull(); + }); +}); + +// ── Query Helpers ─────────────────────────────────────────── + +describe('buildCatalogSortClause', () => { + it('returns downloads DESC for popular', () => { + expect(buildCatalogSortClause('popular')).toContain('downloads DESC'); + }); + + it('returns createdAt DESC for recent', () => { + expect(buildCatalogSortClause('recent')).toContain('createdAt DESC'); + }); + + it('returns rating DESC for rating', () => { + expect(buildCatalogSortClause('rating')).toContain('rating DESC'); + }); +}); + +describe('buildCatalogWhereClause', () => { + it('builds basic where clause', () => { + const { where, parameters } = buildCatalogWhereClause( + { sort: 'popular', limit: 20, offset: 0 }, + 'jarvisjr' + ); + expect(where).toContain('c.productId = @productId'); + expect(where).toContain("c.status = 'published'"); + expect(parameters).toContainEqual({ name: '@productId', value: 'jarvisjr' }); + }); + + it('adds category filter', () => { + const { where, parameters } = buildCatalogWhereClause( + { category: 'coaching', sort: 'popular', limit: 20, offset: 0 }, + 'jarvisjr' + ); + expect(where).toContain('c.category = @category'); + expect(parameters).toContainEqual({ name: '@category', value: 'coaching' }); + }); + + it('adds search filter', () => { + const { where, parameters } = buildCatalogWhereClause( + { search: 'interview', sort: 'popular', limit: 20, offset: 0 }, + 'jarvisjr' + ); + expect(where).toContain('CONTAINS(LOWER(c.title), LOWER(@search))'); + expect(parameters).toContainEqual({ name: '@search', value: 'interview' }); + }); +}); + +describe('buildListWhereClause', () => { + it('builds basic where clause', () => { + const { where, parameters } = buildListWhereClause({ limit: 50, offset: 0 }, 'jarvisjr'); + expect(where).toContain('c.productId = @productId'); + expect(parameters).toContainEqual({ name: '@productId', value: 'jarvisjr' }); + }); + + it('adds status filter', () => { + const { where, parameters } = buildListWhereClause( + { status: 'pending', limit: 50, offset: 0 }, + 'jarvisjr' + ); + expect(where).toContain('c.status = @status'); + expect(parameters).toContainEqual({ name: '@status', value: 'pending' }); + }); + + it('supports extra conditions', () => { + const { where } = buildListWhereClause( + { limit: 50, offset: 0 }, + 'jarvisjr', + ['c.authorId = @authorId'], + [{ name: '@authorId', value: 'user_1' }] + ); + expect(where).toContain('c.authorId = @authorId'); + }); +}); diff --git a/services/platform-service/src/modules/marketplace/repository.ts b/services/platform-service/src/modules/marketplace/repository.ts new file mode 100644 index 00000000..0caa1286 --- /dev/null +++ b/services/platform-service/src/modules/marketplace/repository.ts @@ -0,0 +1,194 @@ +/** + * Marketplace repository — Cosmos DB operations for listings, reviews, installs, + * certifications, and reports. + */ + +import type { + ListingDoc, + ReviewDoc, + InstallDoc, + CertificationDoc, + ReportDoc, + CreateListingInput, + UpdateListingInput, + CreateReviewInput, + CreateReportInput, + BrowseCatalogQuery, + ListQuery, +} from './types.js'; + +// ── Listings ──────────────────────────────────────────────── + +export function buildListingDoc( + input: CreateListingInput, + authorId: string, + productId: string +): ListingDoc { + const now = new Date().toISOString(); + return { + id: `listing_${crypto.randomUUID()}`, + productId, + authorId, + title: input.title, + description: input.description, + category: input.category, + tags: input.tags ?? [], + agentConfig: input.agentConfig, + status: 'draft', + version: input.version ?? '1.0.0', + downloads: 0, + rating: 0, + reviewCount: 0, + isFeatured: false, + isVerified: false, + createdAt: now, + updatedAt: now, + }; +} + +export function applyListingUpdate(doc: ListingDoc, input: UpdateListingInput): ListingDoc { + return { + ...doc, + ...(input.title !== undefined && { title: input.title }), + ...(input.description !== undefined && { description: input.description }), + ...(input.category !== undefined && { category: input.category }), + ...(input.tags !== undefined && { tags: input.tags }), + ...(input.agentConfig !== undefined && { agentConfig: input.agentConfig }), + ...(input.version !== undefined && { version: input.version }), + updatedAt: new Date().toISOString(), + }; +} + +// ── Reviews ───────────────────────────────────────────────── + +export function buildReviewDoc( + input: CreateReviewInput, + listingId: string, + userId: string, + productId: string +): ReviewDoc { + const now = new Date().toISOString(); + return { + id: `review_${crypto.randomUUID()}`, + listingId, + userId, + productId, + rating: input.rating, + comment: input.comment, + createdAt: now, + updatedAt: now, + }; +} + +// ── Installs ──────────────────────────────────────────────── + +export function buildInstallDoc(listingId: string, userId: string, productId: string): InstallDoc { + return { + id: `install_${crypto.randomUUID()}`, + listingId, + userId, + productId, + installedAt: new Date().toISOString(), + }; +} + +// ── Certifications ────────────────────────────────────────── + +export function buildCertificationDoc(listingId: string, productId: string): CertificationDoc { + return { + id: `cert_${crypto.randomUUID()}`, + listingId, + productId, + status: 'pending', + reviewerId: null, + notes: '', + submittedAt: new Date().toISOString(), + reviewedAt: null, + }; +} + +// ── Reports ───────────────────────────────────────────────── + +export function buildReportDoc( + input: CreateReportInput, + listingId: string, + reporterId: string, + productId: string +): ReportDoc { + return { + id: `report_${crypto.randomUUID()}`, + listingId, + reporterId, + productId, + reason: input.reason, + details: input.details, + status: 'open', + resolvedBy: null, + resolution: null, + createdAt: new Date().toISOString(), + resolvedAt: null, + }; +} + +// ── Query Helpers ─────────────────────────────────────────── + +export function buildCatalogSortClause(sort: BrowseCatalogQuery['sort']): string { + switch (sort) { + case 'popular': + return 'ORDER BY c.downloads DESC'; + case 'recent': + return 'ORDER BY c.createdAt DESC'; + case 'rating': + return 'ORDER BY c.rating DESC'; + default: + return 'ORDER BY c.downloads DESC'; + } +} + +export function buildCatalogWhereClause( + query: BrowseCatalogQuery, + productId: string +): { where: string; parameters: Array<{ name: string; value: unknown }> } { + const conditions = ['c.productId = @productId', "c.status = 'published'"]; + const parameters: Array<{ name: string; value: unknown }> = [ + { name: '@productId', value: productId }, + ]; + + if (query.category) { + conditions.push('c.category = @category'); + parameters.push({ name: '@category', value: query.category }); + } + + if (query.search) { + conditions.push('CONTAINS(LOWER(c.title), LOWER(@search))'); + parameters.push({ name: '@search', value: query.search }); + } + + return { + where: conditions.join(' AND '), + parameters, + }; +} + +export function buildListWhereClause( + query: ListQuery, + productId: string, + extraConditions?: string[], + extraParams?: Array<{ name: string; value: unknown }> +): { where: string; parameters: Array<{ name: string; value: unknown }> } { + const conditions = ['c.productId = @productId', ...(extraConditions ?? [])]; + const parameters: Array<{ name: string; value: unknown }> = [ + { name: '@productId', value: productId }, + ...(extraParams ?? []), + ]; + + if (query.status) { + conditions.push('c.status = @status'); + parameters.push({ name: '@status', value: query.status }); + } + + return { + where: conditions.join(' AND '), + parameters, + }; +} diff --git a/services/platform-service/src/modules/marketplace/types.ts b/services/platform-service/src/modules/marketplace/types.ts new file mode 100644 index 00000000..38f20b31 --- /dev/null +++ b/services/platform-service/src/modules/marketplace/types.ts @@ -0,0 +1,167 @@ +/** + * Generic marketplace types — product-agnostic. + * Listings, reviews, installs, certifications, reports. + * Partition key: /productId for listings, /userId for installs/reviews. + */ + +import { z } from 'zod'; + +// ── Constants ─────────────────────────────────────────────── + +export const LISTING_STATUSES = ['draft', 'pending', 'published', 'suspended'] as const; +export const LISTING_CATEGORIES = [ + 'coaching', + 'language', + 'creativity', + 'productivity', + 'wellness', + 'education', + 'business', + 'entertainment', + 'other', +] as const; +export const CERTIFICATION_STATUSES = ['pending', 'approved', 'rejected'] as const; +export const REPORT_STATUSES = ['open', 'investigating', 'resolved', 'dismissed'] as const; +export const REPORT_REASONS = [ + 'inappropriate_content', + 'misleading', + 'spam', + 'intellectual_property', + 'safety_concern', + 'other', +] as const; + +export type ListingStatus = (typeof LISTING_STATUSES)[number]; +export type ListingCategory = (typeof LISTING_CATEGORIES)[number]; +export type CertificationStatus = (typeof CERTIFICATION_STATUSES)[number]; +export type ReportStatus = (typeof REPORT_STATUSES)[number]; +export type ReportReason = (typeof REPORT_REASONS)[number]; + +// ── Document Interfaces ───────────────────────────────────── + +export interface ListingDoc { + id: string; + productId: string; + authorId: string; + title: string; + description: string; + category: ListingCategory; + tags: string[]; + agentConfig: Record; + status: ListingStatus; + version: string; + downloads: number; + rating: number; + reviewCount: number; + isFeatured: boolean; + isVerified: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ReviewDoc { + id: string; + listingId: string; + userId: string; + productId: string; + rating: number; + comment: string; + createdAt: string; + updatedAt: string; +} + +export interface InstallDoc { + id: string; + listingId: string; + userId: string; + productId: string; + installedAt: string; +} + +export interface CertificationDoc { + id: string; + listingId: string; + productId: string; + status: CertificationStatus; + reviewerId: string | null; + notes: string; + submittedAt: string; + reviewedAt: string | null; +} + +export interface ReportDoc { + id: string; + listingId: string; + reporterId: string; + productId: string; + reason: ReportReason; + details: string; + status: ReportStatus; + resolvedBy: string | null; + resolution: string | null; + createdAt: string; + resolvedAt: string | null; +} + +// ── Zod Schemas ───────────────────────────────────────────── + +export const CreateListingSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().min(1).max(5000), + category: z.enum(LISTING_CATEGORIES), + tags: z.array(z.string().max(50)).max(10).default([]), + agentConfig: z.record(z.unknown()), + version: z.string().max(20).default('1.0.0'), +}); + +export const UpdateListingSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().min(1).max(5000).optional(), + category: z.enum(LISTING_CATEGORIES).optional(), + tags: z.array(z.string().max(50)).max(10).optional(), + agentConfig: z.record(z.unknown()).optional(), + version: z.string().max(20).optional(), +}); + +export const CreateReviewSchema = z.object({ + rating: z.number().min(1).max(5), + comment: z.string().min(1).max(2000), +}); + +export const CreateReportSchema = z.object({ + reason: z.enum(REPORT_REASONS), + details: z.string().min(1).max(2000), +}); + +export const ResolveReportSchema = z.object({ + resolution: z.string().min(1).max(2000), +}); + +export const CertificationDecisionSchema = z.object({ + notes: z.string().max(2000).default(''), +}); + +export const BrowseCatalogQuerySchema = z.object({ + category: z.enum(LISTING_CATEGORIES).optional(), + search: z.string().max(200).optional(), + sort: z.enum(['popular', 'recent', 'rating']).default('popular'), + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0), +}); + +export const ListQuerySchema = z.object({ + status: z.enum(LISTING_STATUSES).optional(), + limit: z.coerce.number().min(1).max(100).default(50), + offset: z.coerce.number().min(0).default(0), +}); + +// ── Inferred Types ────────────────────────────────────────── + +export type CreateListingInput = z.infer; +export type UpdateListingInput = z.infer; +export type CreateReviewInput = z.infer; +export type CreateReportInput = z.infer; +export type ResolveReportInput = z.infer; +export type CertificationDecisionInput = z.infer; +export type BrowseCatalogQuery = z.infer; +export type ListQuery = z.infer;