diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index e0377319..2c481e27 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -63,6 +63,7 @@ const CONTAINER_DEFS: Record = { marketplace_installs: { partitionKeyPath: '/userId' }, marketplace_certifications: { partitionKeyPath: '/listingId' }, marketplace_reports: { partitionKeyPath: '/listingId' }, + marketplace_votes: { partitionKeyPath: '/listingId' }, // P2 — Product Intelligence experiments: { partitionKeyPath: '/id' }, experiment_assignments: { partitionKeyPath: '/experimentId' }, diff --git a/services/platform-service/src/modules/marketplace/repository.ts b/services/platform-service/src/modules/marketplace/repository.ts index 9034b764..4620ecfc 100644 --- a/services/platform-service/src/modules/marketplace/repository.ts +++ b/services/platform-service/src/modules/marketplace/repository.ts @@ -11,10 +11,12 @@ import type { MarketplaceInstallDoc, MarketplaceCertificationDoc, MarketplaceReportDoc, + MarketplaceVoteDoc, ListListingsQuery, ListReviewsQuery, ListInstallsQuery, ListReportsQuery, + ListMyReportsQuery, } from './types.js'; // ───────────────────────────────────────────────────────────────────────────── @@ -378,13 +380,46 @@ export async function updateReport( } } -export async function listMyReports(reporterId: string): Promise { +export async function listMyReports(reporterId: string, query?: ListMyReportsQuery): Promise { + const filter: FilterMap = { reporterId }; + if (query?.productId) filter.productId = query.productId; + return reportsCollection().findMany({ - filter: { reporterId }, + filter, sort: { createdAt: -1 }, }); } +// ───────────────────────────────────────────────────────────────────────────── +// Votes (to prevent duplicate voting) +// ───────────────────────────────────────────────────────────────────────────── + +function votesCollection() { + return getCollection('marketplace_votes', '/id'); +} + +export async function getVoteByUserAndListing( + userId: string, + listingId: string +): Promise { + return votesCollection().findOne({ + filter: { userId, listingId }, + }); +} + +export async function createVote(doc: MarketplaceVoteDoc): Promise { + return votesCollection().create(doc); +} + +export async function deleteVote(id: string): Promise { + try { + await votesCollection().delete(id, id); + return true; + } catch { + return false; + } +} + // ───────────────────────────────────────────────────────────────────────────── // Aggregate Stats // ───────────────────────────────────────────────────────────────────────────── diff --git a/services/platform-service/src/modules/marketplace/routes.ts b/services/platform-service/src/modules/marketplace/routes.ts index 0cf6cdc2..61e9be22 100644 --- a/services/platform-service/src/modules/marketplace/routes.ts +++ b/services/platform-service/src/modules/marketplace/routes.ts @@ -30,6 +30,7 @@ import { type MarketplaceInstallDoc, type MarketplaceCertificationDoc, type MarketplaceReportDoc, + type MarketplaceVoteDoc, } from './types.js'; // ───────────────────────────────────────────────────────────────────────────── @@ -435,9 +436,9 @@ async function consumerRoutes(app: FastifyInstance) { return { success: true }; }); - // Vote on listing (toggle upvote) + // Vote on listing (toggle upvote) - prevents duplicate votes app.post('/marketplace/listings/:id/vote', async req => { - requireAuth(req); + const userId = requireAuth(req); const { id } = req.params as { id: string }; const listing = await repo.getListingById(id); @@ -446,8 +447,28 @@ async function consumerRoutes(app: FastifyInstance) { throw new NotFoundError('Listing not found'); } - // Simple vote tracking using a cookie-like approach with listing metadata - // In production, you'd have a separate votes collection + // Check if user already voted + const existingVote = await repo.getVoteByUserAndListing(userId, id); + if (existingVote) { + // Toggle off: remove vote and decrement count + await repo.deleteVote(existingVote.id); + const newVoteCount = Math.max(0, listing.voteCount - 1); + await repo.updateListingStats(id, { voteCount: newVoteCount }); + return { voted: false, voteCount: newVoteCount }; + } + + // Create new vote + const now = new Date().toISOString(); + const voteDoc: MarketplaceVoteDoc = { + id: generateId('vote'), + listingId: id, + productId: listing.productId, + userId: userId, + createdAt: now, + }; + await repo.createVote(voteDoc); + + // Increment vote count const newVoteCount = listing.voteCount + 1; await repo.updateListingStats(id, { voteCount: newVoteCount }); @@ -751,7 +772,8 @@ async function reportRoutes(app: FastifyInstance) { // List my submitted reports app.get('/marketplace/reports/mine', async req => { const userId = requireAuth(req); - const reports = await repo.listMyReports(userId); + const pid = getRequestProductId(req); + const reports = await repo.listMyReports(userId, { productId: pid }); return { reports }; }); } diff --git a/services/platform-service/src/modules/marketplace/types.ts b/services/platform-service/src/modules/marketplace/types.ts index 9d383573..41d11a2a 100644 --- a/services/platform-service/src/modules/marketplace/types.ts +++ b/services/platform-service/src/modules/marketplace/types.ts @@ -290,3 +290,19 @@ export const ListReportsQuerySchema = z.object({ export type CreateReportInput = z.infer; export type ResolveReportInput = z.infer; export type ListReportsQuery = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Vote Types (to prevent duplicate voting) +// ───────────────────────────────────────────────────────────────────────────── + +export interface MarketplaceVoteDoc { + id: string; // vote_ + listingId: string; + productId: string; + userId: string; + createdAt: string; +} + +export type ListMyReportsQuery = { + productId?: string; +}; diff --git a/services/platform-service/src/modules/referrals/migration-repository.ts b/services/platform-service/src/modules/referrals/migration-repository.ts index 102a880d..f7ae0c9b 100644 --- a/services/platform-service/src/modules/referrals/migration-repository.ts +++ b/services/platform-service/src/modules/referrals/migration-repository.ts @@ -52,9 +52,9 @@ async function dualWrite( if (mode === 'dual-write' && oldFn) { try { await oldFn(oldCollection()); - } catch (err) { + } catch (err: unknown) { // Log but don't fail — new container is source of truth - console.warn('[referrals-migration] Old container write failed:', err); + process.stderr.write(`[referrals-migration] Old container write failed: ${err}\n`); } } @@ -112,7 +112,7 @@ async function listWithMerge( const [newResults, oldResults] = await Promise.all([ fn(newCollection()), oldFn(oldCollection()).catch((err) => { - console.warn('[referrals-migration] Old container read failed:', err); + process.stderr.write(`[referrals-migration] Old container read failed: ${err}\n`); return [] as T[]; }), ]); @@ -252,8 +252,8 @@ export async function update( if (getMigrationMode() === 'dual-write') { try { await oldCollection().update(id, id, updates); - } catch (err) { - console.warn('[referrals-migration] Old container update failed:', err); + } catch (err: unknown) { + process.stderr.write(`[referrals-migration] Old container update failed: ${err}\n`); } } diff --git a/services/platform-service/src/modules/waitlist/routes.ts b/services/platform-service/src/modules/waitlist/routes.ts index d187fc7d..d126dba3 100644 --- a/services/platform-service/src/modules/waitlist/routes.ts +++ b/services/platform-service/src/modules/waitlist/routes.ts @@ -25,6 +25,7 @@ import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError, ForbiddenError, NotFoundError } from '../../lib/errors.js'; import { getProduct } from '../products/cache.js'; import { dispatchWaitlistJoined } from '../../lib/webhooks.js'; +import * as auditRepo from '../audit/repository.js'; import type { CustomField } from '../products/types.js'; import * as repo from './repository.js'; import { @@ -381,8 +382,18 @@ export async function waitlistRoutes(app: FastifyInstance) { const ok = await repo.remove(id, entry.email); if (!ok) throw new NotFoundError('Delete failed'); - // TODO-2: Create audit log entry for admin delete action - // await auditRepo.create({ userId: req.jwtPayload!.sub, action: 'waitlist.delete', ... }) + // Create audit log entry for admin delete action + await auditRepo.create({ + id: `audit_${crypto.randomUUID()}`, + productId: entry.productId, + userId: req.jwtPayload!.sub, + action: 'waitlist.delete', + category: 'waitlist', + details: { entryId: id, email: entry.email }, + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + createdAt: new Date().toISOString(), + }); reply.code(204); return; @@ -432,8 +443,18 @@ export async function waitlistRoutes(app: FastifyInstance) { } } - // TODO-2: Create audit log entry for batch invite - // await auditRepo.create({ userId: req.jwtPayload!.sub, action: 'waitlist.invite', ... }) + // Create audit log entry for batch invite + await auditRepo.create({ + id: `audit_${crypto.randomUUID()}`, + productId, + userId: req.jwtPayload!.sub, + action: 'waitlist.invite', + category: 'waitlist', + details: { invited, failed, total: entries.length, strategy }, + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + createdAt: new Date().toISOString(), + }); return { invited, @@ -484,7 +505,18 @@ export async function waitlistRoutes(app: FastifyInstance) { csvLines.push(row.join(',')); } - // TODO-2: Create audit log entry for export action + // Create audit log entry for export action + await auditRepo.create({ + id: `audit_${crypto.randomUUID()}`, + productId, + userId: req.jwtPayload!.sub, + action: 'waitlist.export', + category: 'waitlist', + details: { recordCount: items.length }, + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + createdAt: new Date().toISOString(), + }); reply.header('Content-Type', 'text/csv'); reply.header(