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:
parent
770bc5ae51
commit
e943f14608
@ -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' },
|
||||
|
||||
@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user