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_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' },
|
||||||
|
|||||||
@ -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
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user