feat(platform): marketplace routes — 30 Fastify endpoints (catalog, consumer, author, admin)

This commit is contained in:
saravanakumardb1 2026-03-01 08:04:39 -08:00
parent 761a0c17f4
commit 7aed0ebec3
2 changed files with 422 additions and 0 deletions

View File

@ -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<string, ReturnType<typeof buildListingDoc>> = new Map();
const reviews: Map<string, ReturnType<typeof buildReviewDoc>> = new Map();
const installs: Map<string, ReturnType<typeof buildInstallDoc>> = new Map();
const certifications: Map<string, ReturnType<typeof buildCertificationDoc>> = new Map();
const reports: Map<string, ReturnType<typeof buildReportDoc>> = new Map();
function getProductId(req: { headers: Record<string, string | string[] | undefined> }): 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,
};
});
}

View File

@ -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<typeof CreateListingSchema>;