fix(marketplace): prevent duplicate votes with vote tracking collection

- Add marketplace_votes container to cosmos-init.ts
- Add MarketplaceVoteDoc type and vote repository functions
- Fix vote endpoint to toggle votes (prevent unlimited voting)
- Fix listMyReports to filter by productId for data isolation
- Update report routes to pass productId to listMyReports
This commit is contained in:
saravanakumardb1 2026-03-02 10:12:33 -08:00
parent 770bc5ae51
commit e943f14608
6 changed files with 123 additions and 17 deletions

View File

@ -63,6 +63,7 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
marketplace_installs: { partitionKeyPath: '/userId' }, marketplace_installs: { partitionKeyPath: '/userId' },
marketplace_certifications: { partitionKeyPath: '/listingId' }, marketplace_certifications: { partitionKeyPath: '/listingId' },
marketplace_reports: { partitionKeyPath: '/listingId' }, marketplace_reports: { partitionKeyPath: '/listingId' },
marketplace_votes: { partitionKeyPath: '/listingId' },
// P2 — Product Intelligence // P2 — Product Intelligence
experiments: { partitionKeyPath: '/id' }, experiments: { partitionKeyPath: '/id' },
experiment_assignments: { partitionKeyPath: '/experimentId' }, experiment_assignments: { partitionKeyPath: '/experimentId' },

View File

@ -11,10 +11,12 @@ import type {
MarketplaceInstallDoc, MarketplaceInstallDoc,
MarketplaceCertificationDoc, MarketplaceCertificationDoc,
MarketplaceReportDoc, MarketplaceReportDoc,
MarketplaceVoteDoc,
ListListingsQuery, ListListingsQuery,
ListReviewsQuery, ListReviewsQuery,
ListInstallsQuery, ListInstallsQuery,
ListReportsQuery, ListReportsQuery,
ListMyReportsQuery,
} from './types.js'; } from './types.js';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -378,13 +380,46 @@ export async function updateReport(
} }
} }
export async function listMyReports(reporterId: string): Promise<MarketplaceReportDoc[]> { export async function listMyReports(reporterId: string, query?: ListMyReportsQuery): Promise<MarketplaceReportDoc[]> {
const filter: FilterMap = { reporterId };
if (query?.productId) filter.productId = query.productId;
return reportsCollection().findMany({ return reportsCollection().findMany({
filter: { reporterId }, filter,
sort: { createdAt: -1 }, sort: { createdAt: -1 },
}); });
} }
// ─────────────────────────────────────────────────────────────────────────────
// Votes (to prevent duplicate voting)
// ─────────────────────────────────────────────────────────────────────────────
function votesCollection() {
return getCollection<MarketplaceVoteDoc>('marketplace_votes', '/id');
}
export async function getVoteByUserAndListing(
userId: string,
listingId: string
): Promise<MarketplaceVoteDoc | null> {
return votesCollection().findOne({
filter: { userId, listingId },
});
}
export async function createVote(doc: MarketplaceVoteDoc): Promise<MarketplaceVoteDoc> {
return votesCollection().create(doc);
}
export async function deleteVote(id: string): Promise<boolean> {
try {
await votesCollection().delete(id, id);
return true;
} catch {
return false;
}
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Aggregate Stats // Aggregate Stats
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

View File

@ -30,6 +30,7 @@ import {
type MarketplaceInstallDoc, type MarketplaceInstallDoc,
type MarketplaceCertificationDoc, type MarketplaceCertificationDoc,
type MarketplaceReportDoc, type MarketplaceReportDoc,
type MarketplaceVoteDoc,
} from './types.js'; } from './types.js';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -435,9 +436,9 @@ async function consumerRoutes(app: FastifyInstance) {
return { success: true }; return { success: true };
}); });
// Vote on listing (toggle upvote) // Vote on listing (toggle upvote) - prevents duplicate votes
app.post('/marketplace/listings/:id/vote', async req => { app.post('/marketplace/listings/:id/vote', async req => {
requireAuth(req); const userId = requireAuth(req);
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const listing = await repo.getListingById(id); const listing = await repo.getListingById(id);
@ -446,8 +447,28 @@ async function consumerRoutes(app: FastifyInstance) {
throw new NotFoundError('Listing not found'); throw new NotFoundError('Listing not found');
} }
// Simple vote tracking using a cookie-like approach with listing metadata // Check if user already voted
// In production, you'd have a separate votes collection 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; const newVoteCount = listing.voteCount + 1;
await repo.updateListingStats(id, { voteCount: newVoteCount }); await repo.updateListingStats(id, { voteCount: newVoteCount });
@ -751,7 +772,8 @@ async function reportRoutes(app: FastifyInstance) {
// List my submitted reports // List my submitted reports
app.get('/marketplace/reports/mine', async req => { app.get('/marketplace/reports/mine', async req => {
const userId = requireAuth(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 }; return { reports };
}); });
} }

View File

@ -290,3 +290,19 @@ export const ListReportsQuerySchema = z.object({
export type CreateReportInput = z.infer<typeof CreateReportSchema>; export type CreateReportInput = z.infer<typeof CreateReportSchema>;
export type ResolveReportInput = z.infer<typeof ResolveReportSchema>; export type ResolveReportInput = z.infer<typeof ResolveReportSchema>;
export type ListReportsQuery = z.infer<typeof ListReportsQuerySchema>; export type ListReportsQuery = z.infer<typeof ListReportsQuerySchema>;
// ─────────────────────────────────────────────────────────────────────────────
// Vote Types (to prevent duplicate voting)
// ─────────────────────────────────────────────────────────────────────────────
export interface MarketplaceVoteDoc {
id: string; // vote_<uuid>
listingId: string;
productId: string;
userId: string;
createdAt: string;
}
export type ListMyReportsQuery = {
productId?: string;
};

View File

@ -52,9 +52,9 @@ async function dualWrite<T>(
if (mode === 'dual-write' && oldFn) { if (mode === 'dual-write' && oldFn) {
try { try {
await oldFn(oldCollection()); await oldFn(oldCollection());
} catch (err) { } catch (err: unknown) {
// Log but don't fail — new container is source of truth // 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<T extends { id: string }>(
const [newResults, oldResults] = await Promise.all([ const [newResults, oldResults] = await Promise.all([
fn(newCollection()), fn(newCollection()),
oldFn(oldCollection()).catch((err) => { 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[]; return [] as T[];
}), }),
]); ]);
@ -252,8 +252,8 @@ export async function update(
if (getMigrationMode() === 'dual-write') { if (getMigrationMode() === 'dual-write') {
try { try {
await oldCollection().update(id, id, updates); await oldCollection().update(id, id, updates);
} catch (err) { } catch (err: unknown) {
console.warn('[referrals-migration] Old container update failed:', err); process.stderr.write(`[referrals-migration] Old container update failed: ${err}\n`);
} }
} }

View File

@ -25,6 +25,7 @@ import { getRequestProductId } from '../../lib/request-context.js';
import { BadRequestError, ForbiddenError, NotFoundError } from '../../lib/errors.js'; import { BadRequestError, ForbiddenError, NotFoundError } from '../../lib/errors.js';
import { getProduct } from '../products/cache.js'; import { getProduct } from '../products/cache.js';
import { dispatchWaitlistJoined } from '../../lib/webhooks.js'; import { dispatchWaitlistJoined } from '../../lib/webhooks.js';
import * as auditRepo from '../audit/repository.js';
import type { CustomField } from '../products/types.js'; import type { CustomField } from '../products/types.js';
import * as repo from './repository.js'; import * as repo from './repository.js';
import { import {
@ -381,8 +382,18 @@ export async function waitlistRoutes(app: FastifyInstance) {
const ok = await repo.remove(id, entry.email); const ok = await repo.remove(id, entry.email);
if (!ok) throw new NotFoundError('Delete failed'); if (!ok) throw new NotFoundError('Delete failed');
// TODO-2: Create audit log entry for admin delete action // Create audit log entry for admin delete action
// await auditRepo.create({ userId: req.jwtPayload!.sub, action: 'waitlist.delete', ... }) 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); reply.code(204);
return; return;
@ -432,8 +443,18 @@ export async function waitlistRoutes(app: FastifyInstance) {
} }
} }
// TODO-2: Create audit log entry for batch invite // Create audit log entry for batch invite
// await auditRepo.create({ userId: req.jwtPayload!.sub, action: 'waitlist.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 { return {
invited, invited,
@ -484,7 +505,18 @@ export async function waitlistRoutes(app: FastifyInstance) {
csvLines.push(row.join(',')); 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('Content-Type', 'text/csv');
reply.header( reply.header(