Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cuddly-terms-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds reviews and ratings to the marketplace. Users can submit star ratings (1-5) with optional text reviews. Plugin owners can reply to reviews. Ratings are shown on plugin detail pages after 3+ reviews.
2 changes: 2 additions & 0 deletions packages/marketplace/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { authorRoutes } from "./routes/author.js";
import { devRoutes } from "./routes/dev.js";
import { imageRoutes } from "./routes/images.js";
import { publicRoutes } from "./routes/public.js";
import { reviewRoutes } from "./routes/reviews.js";
import { statsRoutes } from "./routes/stats.js";
import { themeRoutes } from "./routes/themes.js";

Expand All @@ -28,6 +29,7 @@ app.get("/health", (c) => c.json({ status: "ok" }));

app.route("/api/v1", publicRoutes);
app.route("/api/v1", authorRoutes);
app.route("/api/v1", reviewRoutes);
app.route("/api/v1", themeRoutes);
app.route("/api/v1", statsRoutes);
app.route("/api/v1", imageRoutes);
Expand Down
200 changes: 200 additions & 0 deletions packages/marketplace/src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
PluginSearchResult,
PluginVersionRow,
PluginWithAuthor,
ReviewWithAuthor,
SearchOptions,
ThemeRow,
ThemeSearchOptions,
Expand Down Expand Up @@ -97,6 +98,10 @@ export async function searchPlugins(
case "updated":
orderBy = "p.updated_at DESC";
break;
case "rating":
orderBy =
"CASE WHEN p.rating_count >= 3 THEN p.rating_avg END DESC, p.rating_count DESC, p.created_at DESC";
break;
case "installs":
default:
orderBy = "p.install_count DESC, p.created_at DESC";
Expand Down Expand Up @@ -198,6 +203,201 @@ export async function getPluginVersion(
.first<PluginVersionRow>();
}

// ── Review queries ──────────────────────────────────────────────

/** Strip HTML/script tags from review text. Plain text only. */
const HTML_TAG_RE = /<[^>]*>/g;

function sanitizeReviewText(text: string): string {
return text.replace(HTML_TAG_RE, "").trim();
}

export async function getPluginReviews(
db: D1Database,
pluginId: string,
opts?: { cursor?: string; limit?: number },
): Promise<{ items: ReviewWithAuthor[]; nextCursor?: string }> {
const limit = clampLimit(opts?.limit);
const offset = decodeCursor(opts?.cursor);

const result = await db
.prepare(
`SELECT r.*, a.name AS author_name, a.avatar_url AS author_avatar_url
FROM reviews r
JOIN authors a ON a.id = r.author_id
WHERE r.plugin_id = ?
ORDER BY r.created_at DESC
LIMIT ? OFFSET ?`,
)
.bind(pluginId, limit + 1, offset)
.all<ReviewWithAuthor>();

const items = result.results ?? [];
let nextCursor: string | undefined;
if (items.length > limit) {
items.pop();
nextCursor = encodeCursor(offset + limit);
}

return { items, nextCursor };
}

export async function createReview(
db: D1Database,
data: { pluginId: string; authorId: string; rating: number; body?: string },
): Promise<ReviewWithAuthor> {
const id = generateId();
const sanitizedBody = data.body ? sanitizeReviewText(data.body) : null;

await db
.prepare(
`INSERT INTO reviews (id, plugin_id, author_id, rating, body)
VALUES (?, ?, ?, ?, ?)`,
)
.bind(id, data.pluginId, data.authorId, data.rating, sanitizedBody)
.run();

// Atomically recalculate rating on the plugins table
await recalculatePluginRating(db, data.pluginId);

return (await db
.prepare(
`SELECT r.*, a.name AS author_name, a.avatar_url AS author_avatar_url
FROM reviews r JOIN authors a ON a.id = r.author_id
WHERE r.id = ?`,
)
.bind(id)
.first<ReviewWithAuthor>())!;
}

export async function updateReview(
db: D1Database,
reviewId: string,
authorId: string,
data: { rating?: number; body?: string },
pluginId?: string,
): Promise<ReviewWithAuthor | null> {
const existing = await db
.prepare("SELECT * FROM reviews WHERE id = ? AND author_id = ?")
.bind(reviewId, authorId)
.first<{ id: string; plugin_id: string }>();

if (!existing) return null;
if (pluginId && existing.plugin_id !== pluginId) return null;

const sets: string[] = [];
const bindings: unknown[] = [];

if (data.rating !== undefined) {
sets.push("rating = ?");
bindings.push(data.rating);
}
if (data.body !== undefined) {
sets.push("body = ?");
bindings.push(sanitizeReviewText(data.body));
}

if (sets.length === 0) {
return db
.prepare(
`SELECT r.*, a.name AS author_name, a.avatar_url AS author_avatar_url
FROM reviews r JOIN authors a ON a.id = r.author_id WHERE r.id = ?`,
)
.bind(reviewId)
.first<ReviewWithAuthor>();
}

sets.push("updated_at = datetime('now')");
bindings.push(reviewId);

await db
.prepare(`UPDATE reviews SET ${sets.join(", ")} WHERE id = ?`)
.bind(...bindings)
.run();

await recalculatePluginRating(db, existing.plugin_id);

return db
.prepare(
`SELECT r.*, a.name AS author_name, a.avatar_url AS author_avatar_url
FROM reviews r JOIN authors a ON a.id = r.author_id WHERE r.id = ?`,
)
.bind(reviewId)
.first<ReviewWithAuthor>();
}

export async function deleteReview(
db: D1Database,
reviewId: string,
authorId: string,
pluginId?: string,
): Promise<{ deleted: boolean; pluginId?: string }> {
const existing = await db
.prepare("SELECT plugin_id FROM reviews WHERE id = ? AND author_id = ?")
.bind(reviewId, authorId)
.first<{ plugin_id: string }>();

if (!existing) return { deleted: false };
if (pluginId && existing.plugin_id !== pluginId) return { deleted: false };

await db.prepare("DELETE FROM reviews WHERE id = ?").bind(reviewId).run();
await recalculatePluginRating(db, existing.plugin_id);

return { deleted: true, pluginId: existing.plugin_id };
}

export async function addPublisherReply(
db: D1Database,
reviewId: string,
pluginOwnerId: string,
reply: string,
pluginId?: string,
): Promise<ReviewWithAuthor | null> {
// Verify the review exists and the caller owns the plugin
const review = await db
.prepare(
`SELECT r.plugin_id FROM reviews r
JOIN plugins p ON p.id = r.plugin_id
WHERE r.id = ? AND p.author_id = ?`,
)
.bind(reviewId, pluginOwnerId)
.first<{ plugin_id: string }>();

if (!review) return null;
if (pluginId && review.plugin_id !== pluginId) return null;

const sanitizedReply = sanitizeReviewText(reply);
if (!sanitizedReply) return null;

await db
.prepare(
"UPDATE reviews SET publisher_reply = ?, replied_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
)
.bind(sanitizedReply, reviewId)
.run();

return db
.prepare(
`SELECT r.*, a.name AS author_name, a.avatar_url AS author_avatar_url
FROM reviews r JOIN authors a ON a.id = r.author_id WHERE r.id = ?`,
)
.bind(reviewId)
.first<ReviewWithAuthor>();
}

/** Atomically recalculate rating_avg and rating_count from the reviews table. */
async function recalculatePluginRating(db: D1Database, pluginId: string): Promise<void> {
await db
.prepare(
`UPDATE plugins SET
rating_avg = COALESCE((SELECT AVG(CAST(rating AS REAL)) FROM reviews WHERE plugin_id = ?), 0),
rating_count = (SELECT COUNT(*) FROM reviews WHERE plugin_id = ?)
WHERE id = ?`,
)
.bind(pluginId, pluginId, pluginId)
.run();
}

// ── Install queries ─────────────────────────────────────────────

export async function upsertInstall(
Expand Down
21 changes: 21 additions & 0 deletions packages/marketplace/src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ CREATE TABLE IF NOT EXISTS plugins (
keywords TEXT,
has_icon INTEGER DEFAULT 0,
install_count INTEGER NOT NULL DEFAULT 0,
rating_avg REAL NOT NULL DEFAULT 0,
rating_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Expand Down Expand Up @@ -87,6 +89,25 @@ CREATE TABLE IF NOT EXISTS installs (
);
CREATE INDEX IF NOT EXISTS idx_installs_plugin ON installs(plugin_id);

-- ── Reviews ─────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS reviews (
id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL REFERENCES plugins(id),
author_id TEXT NOT NULL REFERENCES authors(id),
rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5),
body TEXT,
publisher_reply TEXT,
replied_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(plugin_id, author_id)
);
CREATE INDEX IF NOT EXISTS idx_reviews_plugin ON reviews(plugin_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(author_id);

-- ── Themes ──────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS themes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down
21 changes: 20 additions & 1 deletion packages/marketplace/src/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,29 @@ export interface PluginRow {
keywords: string | null;
has_icon: number;
install_count: number;
rating_avg: number;
rating_count: number;
created_at: string;
updated_at: string;
}

export interface ReviewRow {
id: string;
plugin_id: string;
author_id: string;
rating: number;
body: string | null;
publisher_reply: string | null;
replied_at: string | null;
created_at: string;
updated_at: string;
}

export interface ReviewWithAuthor extends ReviewRow {
author_name: string;
author_avatar_url: string | null;
}

export type VersionStatus = "pending" | "published" | "flagged" | "rejected";

export interface PluginVersionRow {
Expand Down Expand Up @@ -94,7 +113,7 @@ export interface PluginSearchResult extends PluginWithAuthor {
latest_audit_risk_score: number | null;
}

export type SortOption = "installs" | "updated" | "created" | "name";
export type SortOption = "installs" | "updated" | "created" | "name" | "rating";

export interface SearchOptions {
q?: string;
Expand Down
10 changes: 7 additions & 3 deletions packages/marketplace/src/routes/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ publicRoutes.get("/plugins", async (c) => {
const q = url.searchParams.get("q") ?? undefined;
const capability = url.searchParams.get("capability") ?? undefined;
const sortParam = url.searchParams.get("sort");
const validSorts = new Set(["installs", "updated", "created", "name"]);
let sort: "installs" | "updated" | "created" | "name" | undefined;
const validSorts = new Set(["installs", "updated", "created", "name", "rating"]);
let sort: "installs" | "updated" | "created" | "name" | "rating" | undefined;
if (sortParam && validSorts.has(sortParam)) {
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- validated by Set.has check above
sort = sortParam as "installs" | "updated" | "created" | "name";
sort = sortParam as "installs" | "updated" | "created" | "name" | "rating";
}
const cursor = url.searchParams.get("cursor") ?? undefined;
const limitStr = url.searchParams.get("limit");
Expand Down Expand Up @@ -122,6 +122,10 @@ publicRoutes.get("/plugins/:id", async (c) => {
hasIcon: plugin.has_icon === 1,
iconUrl: `${baseUrl}/api/v1/plugins/${plugin.id}/icon`,
installCount,
rating:
plugin.rating_count >= 3
? { average: Math.round(plugin.rating_avg * 10) / 10, count: plugin.rating_count }
: null,
createdAt: plugin.created_at,
updatedAt: plugin.updated_at,
};
Expand Down
Loading
Loading