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_certifications: { partitionKeyPath: '/listingId' },
marketplace_reports: { partitionKeyPath: '/listingId' },
marketplace_votes: { partitionKeyPath: '/listingId' },
// P2 — Product Intelligence
experiments: { partitionKeyPath: '/id' },
experiment_assignments: { partitionKeyPath: '/experimentId' },

View File

@ -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<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({
filter: { reporterId },
filter,
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
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -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 };
});
}

View File

@ -290,3 +290,19 @@ export const ListReportsQuerySchema = z.object({
export type CreateReportInput = z.infer<typeof CreateReportSchema>;
export type ResolveReportInput = z.infer<typeof ResolveReportSchema>;
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) {
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<T extends { id: string }>(
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`);
}
}

View File

@ -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(