feat(platform): marketplace routes — 30 Fastify endpoints (catalog, consumer, author, admin)
This commit is contained in:
parent
761a0c17f4
commit
7aed0ebec3
416
services/platform-service/src/modules/marketplace/routes.ts
Normal file
416
services/platform-service/src/modules/marketplace/routes.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user