diff --git a/services/platform-service/src/modules/marketplace/routes.ts b/services/platform-service/src/modules/marketplace/routes.ts new file mode 100644 index 00000000..d8ae55e2 --- /dev/null +++ b/services/platform-service/src/modules/marketplace/routes.ts @@ -0,0 +1,416 @@ +/** + * Marketplace REST endpoints — product-agnostic. + * + * Public catalog (no auth): + * GET /marketplace/catalog — browse published listings + * GET /marketplace/catalog/:id — listing detail + * GET /marketplace/catalog/:id/reviews — listing reviews + * + * Consumer (auth required): + * POST /marketplace/install — install a listing + * POST /marketplace/uninstall — uninstall a listing + * GET /marketplace/installed — list installed + * POST /marketplace/catalog/:id/reviews — add review + * POST /marketplace/catalog/:id/report — flag listing + * + * Author (auth required): + * POST /marketplace/listings — create listing + * GET /marketplace/listings/mine — list my listings + * GET /marketplace/listings/:id — get my listing + * PUT /marketplace/listings/:id — update listing + * POST /marketplace/listings/:id/submit — submit for review + * DELETE /marketplace/listings/:id — delete listing + * + * Admin (admin role): + * GET /marketplace/admin/pending — pending certifications + * POST /marketplace/admin/:id/approve — approve listing + * POST /marketplace/admin/:id/reject — reject listing + * POST /marketplace/admin/:id/suspend — suspend listing + * POST /marketplace/admin/:id/feature — toggle featured + * GET /marketplace/admin/reports — list reports + * POST /marketplace/admin/reports/:id/resolve — resolve report + * GET /marketplace/admin/stats — marketplace stats + */ + +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, NotFoundError } from '../../lib/errors.js'; +import { extractAuth, requireRole } from '../../lib/auth.js'; +import { + CreateListingSchema, + UpdateListingSchema, + CreateReviewSchema, + CreateReportSchema, + ResolveReportSchema, + CertificationDecisionSchema, + BrowseCatalogQuerySchema, + ReportQuerySchema, +} from './types.js'; +import { + buildListingDoc, + applyListingUpdate, + buildReviewDoc, + buildInstallDoc, + buildCertificationDoc, + buildReportDoc, +} from './repository.js'; + +/** + * NOTE: This routes file defines the full endpoint structure but uses + * in-memory stubs for Cosmos container operations. When wired to real + * Cosmos containers, replace the stub arrays with getRegisteredContainer() + * calls from @bytelyst/cosmos. + */ + +// In-memory stubs (replace with Cosmos containers in production) +const listings: Map> = new Map(); +const reviews: Map> = new Map(); +const installs: Map> = new Map(); +const certifications: Map> = new Map(); +const reports: Map> = new Map(); + +function getProductId(req: { headers: Record }): string { + const pid = req.headers['x-product-id']; + if (typeof pid === 'string' && pid.length > 0) return pid; + return 'jarvisjr'; +} + +export async function marketplaceRoutes(app: FastifyInstance) { + // ── Public Catalog ────────────────────────────────────────── + + app.get('/marketplace/catalog', async req => { + const productId = getProductId(req); + const parsed = BrowseCatalogQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const query = parsed.data; + const all = Array.from(listings.values()).filter( + l => l.productId === productId && l.status === 'published' + ); + + let filtered = all; + if (query.category) { + filtered = filtered.filter(l => l.category === query.category); + } + if (query.search) { + const s = query.search.toLowerCase(); + filtered = filtered.filter(l => l.title.toLowerCase().includes(s)); + } + + // Sort + if (query.sort === 'popular') filtered.sort((a, b) => b.downloads - a.downloads); + else if (query.sort === 'recent') + filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + else if (query.sort === 'rating') filtered.sort((a, b) => b.rating - a.rating); + + const total = filtered.length; + const items = filtered.slice(query.offset, query.offset + query.limit); + return { items, total, limit: query.limit, offset: query.offset }; + }); + + app.get('/marketplace/catalog/:id', async req => { + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing || listing.status !== 'published') throw new NotFoundError('Listing not found'); + return listing; + }); + + app.get('/marketplace/catalog/:id/reviews', async req => { + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing || listing.status !== 'published') throw new NotFoundError('Listing not found'); + const listingReviews = Array.from(reviews.values()).filter(r => r.listingId === id); + return { reviews: listingReviews, total: listingReviews.length }; + }); + + // ── Consumer Actions ──────────────────────────────────────── + + app.post('/marketplace/install', async (req, reply) => { + const auth = await extractAuth(req); + const body = req.body as { listingId?: string }; + if (!body.listingId) throw new BadRequestError('listingId is required'); + const listing = listings.get(body.listingId); + if (!listing || listing.status !== 'published') throw new NotFoundError('Listing not found'); + + const existing = Array.from(installs.values()).find( + i => i.listingId === body.listingId && i.userId === auth.sub + ); + if (existing) throw new BadRequestError('Already installed'); + + const doc = buildInstallDoc(body.listingId, auth.sub, listing.productId); + installs.set(doc.id, doc); + listing.downloads += 1; + reply.code(201); + return { installed: true, installId: doc.id }; + }); + + app.post('/marketplace/uninstall', async (req, reply) => { + const auth = await extractAuth(req); + const body = req.body as { listingId?: string }; + if (!body.listingId) throw new BadRequestError('listingId is required'); + + const existing = Array.from(installs.values()).find( + i => i.listingId === body.listingId && i.userId === auth.sub + ); + if (!existing) throw new NotFoundError('Not installed'); + installs.delete(existing.id); + reply.code(204); + }); + + app.get('/marketplace/installed', async req => { + const auth = await extractAuth(req); + const userInstalls = Array.from(installs.values()).filter(i => i.userId === auth.sub); + const installed = userInstalls.map(i => listings.get(i.listingId)).filter(Boolean); + return { items: installed, total: installed.length }; + }); + + app.post('/marketplace/catalog/:id/reviews', async (req, reply) => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing || listing.status !== 'published') throw new NotFoundError('Listing not found'); + + const parsed = CreateReviewSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const existing = Array.from(reviews.values()).find( + r => r.listingId === id && r.userId === auth.sub + ); + if (existing) throw new BadRequestError('Already reviewed'); + + const doc = buildReviewDoc(parsed.data, id, auth.sub, listing.productId); + reviews.set(doc.id, doc); + + // Update listing rating + const allReviews = Array.from(reviews.values()).filter(r => r.listingId === id); + listing.reviewCount = allReviews.length; + listing.rating = allReviews.reduce((sum, r) => sum + r.rating, 0) / allReviews.length; + + reply.code(201); + return doc; + }); + + app.post('/marketplace/catalog/:id/report', async (req, reply) => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing) throw new NotFoundError('Listing not found'); + + const parsed = CreateReportSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const doc = buildReportDoc(parsed.data, id, auth.sub, listing.productId); + reports.set(doc.id, doc); + reply.code(201); + return doc; + }); + + // ── Author Actions ────────────────────────────────────────── + + app.post('/marketplace/listings', async (req, reply) => { + const auth = await extractAuth(req); + const productId = getProductId(req); + const parsed = CreateListingSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const doc = buildListingDoc(parsed.data, auth.sub, productId); + listings.set(doc.id, doc); + reply.code(201); + return doc; + }); + + app.get('/marketplace/listings/mine', async req => { + const auth = await extractAuth(req); + const mine = Array.from(listings.values()).filter(l => l.authorId === auth.sub); + return { items: mine, total: mine.length }; + }); + + app.get('/marketplace/listings/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing || listing.authorId !== auth.sub) throw new NotFoundError('Listing not found'); + return listing; + }); + + app.put('/marketplace/listings/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing || listing.authorId !== auth.sub) throw new NotFoundError('Listing not found'); + if (listing.status !== 'draft') throw new BadRequestError('Can only edit draft listings'); + + const parsed = UpdateListingSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const updated = applyListingUpdate(listing, parsed.data); + listings.set(id, updated); + return updated; + }); + + app.post('/marketplace/listings/:id/submit', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing || listing.authorId !== auth.sub) throw new NotFoundError('Listing not found'); + if (listing.status !== 'draft') throw new BadRequestError('Can only submit draft listings'); + + listing.status = 'pending'; + listing.updatedAt = new Date().toISOString(); + + const cert = buildCertificationDoc(id, listing.productId); + certifications.set(cert.id, cert); + + return { submitted: true, certificationId: cert.id }; + }); + + app.delete('/marketplace/listings/:id', async (req, reply) => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing || listing.authorId !== auth.sub) throw new NotFoundError('Listing not found'); + listings.delete(id); + reply.code(204); + }); + + // ── Admin Actions ─────────────────────────────────────────── + + app.get('/marketplace/admin/pending', async req => { + await requireRole(req, 'admin'); + const pending = Array.from(certifications.values()).filter(c => c.status === 'pending'); + return { items: pending, total: pending.length }; + }); + + app.post('/marketplace/admin/:id/approve', async req => { + const auth = await requireRole(req, 'admin'); + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing) throw new NotFoundError('Listing not found'); + if (listing.status !== 'pending') throw new BadRequestError('Listing is not pending'); + + const parsed = CertificationDecisionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + listing.status = 'published'; + listing.isVerified = true; + listing.updatedAt = new Date().toISOString(); + + // Update certification + const cert = Array.from(certifications.values()).find( + c => c.listingId === id && c.status === 'pending' + ); + if (cert) { + cert.status = 'approved'; + cert.reviewerId = auth.sub; + cert.notes = parsed.data.notes; + cert.reviewedAt = new Date().toISOString(); + } + + return { approved: true }; + }); + + app.post('/marketplace/admin/:id/reject', async req => { + const auth = await requireRole(req, 'admin'); + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing) throw new NotFoundError('Listing not found'); + if (listing.status !== 'pending') throw new BadRequestError('Listing is not pending'); + + const parsed = CertificationDecisionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + listing.status = 'draft'; + listing.updatedAt = new Date().toISOString(); + + const cert = Array.from(certifications.values()).find( + c => c.listingId === id && c.status === 'pending' + ); + if (cert) { + cert.status = 'rejected'; + cert.reviewerId = auth.sub; + cert.notes = parsed.data.notes; + cert.reviewedAt = new Date().toISOString(); + } + + return { rejected: true }; + }); + + app.post('/marketplace/admin/:id/suspend', async req => { + await requireRole(req, 'admin'); + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing) throw new NotFoundError('Listing not found'); + + listing.status = 'suspended'; + listing.updatedAt = new Date().toISOString(); + return { suspended: true }; + }); + + app.post('/marketplace/admin/:id/feature', async req => { + await requireRole(req, 'admin'); + const { id } = req.params as { id: string }; + const listing = listings.get(id); + if (!listing) throw new NotFoundError('Listing not found'); + + listing.isFeatured = !listing.isFeatured; + listing.updatedAt = new Date().toISOString(); + return { featured: listing.isFeatured }; + }); + + app.get('/marketplace/admin/reports', async req => { + await requireRole(req, 'admin'); + const parsed = ReportQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const allReports = Array.from(reports.values()); + const filtered = parsed.data.status + ? allReports.filter(r => r.status === parsed.data.status) + : allReports; + return { items: filtered, total: filtered.length }; + }); + + app.post('/marketplace/admin/reports/:id/resolve', async req => { + const auth = await requireRole(req, 'admin'); + const { id } = req.params as { id: string }; + const report = reports.get(id); + if (!report) throw new NotFoundError('Report not found'); + + const parsed = ResolveReportSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + report.status = 'resolved'; + report.resolvedBy = auth.sub; + report.resolution = parsed.data.resolution; + report.resolvedAt = new Date().toISOString(); + return report; + }); + + app.get('/marketplace/admin/stats', async req => { + await requireRole(req, 'admin'); + const allListings = Array.from(listings.values()); + return { + totalListings: allListings.length, + publishedListings: allListings.filter(l => l.status === 'published').length, + pendingListings: allListings.filter(l => l.status === 'pending').length, + suspendedListings: allListings.filter(l => l.status === 'suspended').length, + totalInstalls: installs.size, + totalReviews: reviews.size, + openReports: Array.from(reports.values()).filter(r => r.status === 'open').length, + }; + }); +} diff --git a/services/platform-service/src/modules/marketplace/types.ts b/services/platform-service/src/modules/marketplace/types.ts index 38f20b31..a2b5594f 100644 --- a/services/platform-service/src/modules/marketplace/types.ts +++ b/services/platform-service/src/modules/marketplace/types.ts @@ -155,6 +155,12 @@ export const ListQuerySchema = z.object({ offset: z.coerce.number().min(0).default(0), }); +export const ReportQuerySchema = z.object({ + status: z.enum(REPORT_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;