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),
|
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 ──────────────────────────────────────────
|
// ── Inferred Types ──────────────────────────────────────────
|
||||||
|
|
||||||
export type CreateListingInput = z.infer<typeof CreateListingSchema>;
|
export type CreateListingInput = z.infer<typeof CreateListingSchema>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user