feat: product drafts, moderator edits, info history, unlisted products, tags and categories (#8)
Phase 2 of the plan in `docs/design.md` ("Next"): product info.
**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.
**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.
**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.
**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).
**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.
**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.
**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.
**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.
**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Phase 2 of the plan in `docs/design.md` ("Next"): product info.
**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.
**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.
**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.
**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).
**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.
**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.
**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.
**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.
**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: dhkatz <[email protected]>
This commit was merged in pull request #8.
This commit is contained in:
1 parent
2cbedd26fc
commit
e07a6c6dac
24 files changed
+4248
-95
No files matched your search
+336
-28
@@ -20,12 +20,38 @@ import { cleanHTML, maxDescription } from "./html";
|
||||
import type { ImvuClient } from "./imvu";
|
||||
import type { Signer } from "./keys";
|
||||
|
||||
const { catalog, catalogMember, product, productRevision, hosting, library, roleAssignment, user } = schema;
|
||||
const { catalog, catalogChange, catalogMember, product, productInfoChange, productRevision, hosting, library, roleAssignment, user } =
|
||||
schema;
|
||||
|
||||
export type Catalog = typeof catalog.$inferSelect;
|
||||
export type Product = typeof product.$inferSelect;
|
||||
export type Revision = typeof productRevision.$inferSelect;
|
||||
|
||||
/** Info fields people edit (and moderators lock). */
|
||||
export const infoFields = ["name", "description", "rating", "tags", "listed", "category"] as const;
|
||||
export type InfoField = (typeof infoFields)[number];
|
||||
/** `category`: an IMVU category ID from categoryChoices, or null for its parent's. */
|
||||
export type InfoInput = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
rating?: string;
|
||||
tags?: string[];
|
||||
listed?: boolean;
|
||||
category?: number | null;
|
||||
};
|
||||
|
||||
/** An IMVU category path, outermost first. */
|
||||
export type CategoryPath = { id: number; name: string }[];
|
||||
|
||||
/** What the person editing may do beyond product:edit (from the permission check). */
|
||||
export type EditRights = { creator: boolean; editLocked: boolean; rateLower: boolean };
|
||||
|
||||
/** Who's looking: drafts are only for their creator and moderators. */
|
||||
export type ProductViewer = { id: string; moderator: boolean };
|
||||
|
||||
/** Drafts never published are deleted after this long. */
|
||||
export const draftDays = 30;
|
||||
|
||||
/** An error for the person; the routes turn it into a Problem. */
|
||||
export class ProductError extends Error {
|
||||
constructor(
|
||||
@@ -50,6 +76,19 @@ const nameRule = (s: unknown, max: number) => {
|
||||
return v;
|
||||
};
|
||||
|
||||
/** Tags as people type them: lowercase words (letters, digits, spaces, dashes), at most 10, each up to 24. */
|
||||
export function checkTags(tags: unknown): string[] | undefined {
|
||||
if (!Array.isArray(tags) || tags.length > 10) return undefined;
|
||||
const out: string[] = [];
|
||||
for (const t of tags) {
|
||||
const v = typeof t === "string" ? t.trim().toLowerCase().replace(/\s+/g, " ") : "";
|
||||
if (!v) continue;
|
||||
if (v.length > 24 || !/^[\p{L}\p{N}][\p{L}\p{N} -]*$/u.test(v)) return undefined;
|
||||
if (!out.includes(v)) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A file list as catalogs send it: checked and sorted by name. */
|
||||
export function checkFiles(files: unknown): FileEntry[] | undefined {
|
||||
if (!Array.isArray(files) || files.length === 0 || files.length > 512) return undefined;
|
||||
@@ -75,10 +114,10 @@ const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
|
||||
const parentInfoFresh = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Whether a product is available to a person (docs/products.md, "Member"): they made it, or a catalog hosting it is
|
||||
* approved, hasn't banned them, and is open or has them as a member or on its team.
|
||||
* Whether a product is available to a person (docs/products.md, "Member"): it's published, and they made it, or a
|
||||
* catalog hosting it is approved, hasn't banned them, and is open or has them as a member or on its team.
|
||||
*/
|
||||
export const availableTo = (personId: string) => sql`(
|
||||
export const availableTo = (personId: string) => sql`(${product.status} = 'published' and (
|
||||
${product.creatorId} = ${personId} or exists (
|
||||
select 1 from ${hosting} h
|
||||
join ${catalog} c on c.id = h.catalog_id
|
||||
@@ -87,7 +126,7 @@ export const availableTo = (personId: string) => sql`(
|
||||
and (c.membership = 'open' or m.status = 'member' or c.owner_id = ${personId}
|
||||
or exists (select 1 from ${roleAssignment} r
|
||||
where r.user_id = ${personId} and r.scope_type = 'catalog' and r.scope_id = c.id))
|
||||
))`;
|
||||
)))`;
|
||||
|
||||
/** Each product's latest revision (joined on product.latest, for lists). */
|
||||
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
|
||||
@@ -239,7 +278,7 @@ export class Products {
|
||||
const name = nameRule(body.name, 64) ?? "Untitled";
|
||||
const [made] = await tx
|
||||
.insert(product)
|
||||
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating })
|
||||
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating, status: "draft" })
|
||||
.returning();
|
||||
p = made!;
|
||||
}
|
||||
@@ -265,7 +304,7 @@ export class Products {
|
||||
const rating = p.rating === "adult" || body.rating === "adult" ? "adult" : "general";
|
||||
await tx.update(product).set({ latest: number, rating }).where(eq(product.id, p.id));
|
||||
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
|
||||
return { product: p.id, revision: number };
|
||||
return { product: p.id, revision: number, draft: p.status === "draft" };
|
||||
});
|
||||
// The parent's image and categories: best effort, without holding up the upload (fillParentInfo retries).
|
||||
if (parent > 0) this.lookUpParent(made.product, made.revision, parent).catch(() => {});
|
||||
@@ -354,16 +393,38 @@ export class Products {
|
||||
}
|
||||
|
||||
/**
|
||||
* Products, newest first; adult ones only if adult. `category`: only those whose latest revision's parent is in
|
||||
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info.
|
||||
* Published products, newest first; adult ones only if adult; unlisted ones only for their creator. `category`:
|
||||
* only those whose latest revision's parent is in that IMVU category (anywhere in its path); `tag`: those with
|
||||
* the tag; `catalog`: those the catalog hosts. Each comes with its latest revision's parent and parent info.
|
||||
*/
|
||||
async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) {
|
||||
const conds = [ne(product.latest, 0), isNull(product.deletedAt), isNull(product.removedAt)];
|
||||
async list(opts: {
|
||||
q?: string;
|
||||
creator?: string;
|
||||
category?: number;
|
||||
tag?: string;
|
||||
catalog?: string;
|
||||
adult: boolean;
|
||||
viewerId?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const conds = [
|
||||
ne(product.latest, 0),
|
||||
eq(product.status, "published"),
|
||||
isNull(product.deletedAt),
|
||||
isNull(product.removedAt),
|
||||
opts.viewerId ? or(eq(product.listed, true), eq(product.creatorId, opts.viewerId))! : eq(product.listed, true),
|
||||
];
|
||||
if (!opts.adult) conds.push(eq(product.rating, "general"));
|
||||
if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`));
|
||||
if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase()));
|
||||
if (opts.category !== undefined)
|
||||
conds.push(sql`${productRevision.parentInfo}->'categories' @> ${JSON.stringify([{ id: opts.category }])}::jsonb`);
|
||||
conds.push(
|
||||
sql`coalesce(${product.category}, ${productRevision.parentInfo}->'categories') @> ${JSON.stringify([{ id: opts.category }])}::jsonb`,
|
||||
);
|
||||
const tag = opts.tag !== undefined ? checkTags([opts.tag])?.[0] : undefined;
|
||||
if (opts.tag !== undefined) conds.push(tag ? sql`${product.tags} @> ${JSON.stringify([tag])}::jsonb` : sql`false`);
|
||||
if (opts.catalog)
|
||||
conds.push(sql`exists (select 1 from ${hosting} h where h.product_id = ${product.id} and h.catalog_id = ${opts.catalog})`);
|
||||
const limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200));
|
||||
return this.db
|
||||
.select({
|
||||
@@ -390,7 +451,7 @@ export class Products {
|
||||
return r;
|
||||
}
|
||||
|
||||
async get(id: string, adult: boolean) {
|
||||
async get(id: string, adult: boolean, viewer?: ProductViewer) {
|
||||
const [row] = await this.db
|
||||
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
|
||||
.from(product)
|
||||
@@ -398,6 +459,8 @@ export class Products {
|
||||
.where(eq(product.id, id));
|
||||
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
|
||||
if (row.product.status === "draft" && !(viewer?.moderator || viewer?.id === row.product.creatorId))
|
||||
throw new ProductError(404, "not_found", "No such product.");
|
||||
if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
|
||||
const revisions = await this.db
|
||||
.select()
|
||||
@@ -412,27 +475,271 @@ export class Products {
|
||||
return { ...row, revisions, hosts };
|
||||
}
|
||||
|
||||
async update(personId: string, id: string, input: { name?: string; description?: string; rating?: string }) {
|
||||
const p = await this.own(personId, id);
|
||||
const set: Partial<Product> = {};
|
||||
// ---- product info: edits, history, locks, publishing ----
|
||||
|
||||
/** A product for changing its info: not removed; deleted ones only by others than the creator (moderators). */
|
||||
private async editable(id: string, rights: EditRights) {
|
||||
const [p] = await this.db.select().from(product).where(eq(product.id, id));
|
||||
if (!p || p.latest === 0) throw new ProductError(404, "not_found", "No such product.");
|
||||
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
|
||||
if (p.deletedAt && rights.creator) throw new ProductError(410, "deleted", "You deleted this product.");
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a product's info (the route checked product:edit). Only fields that change are kept in its history.
|
||||
* Someone other than the creator must give a reason; locked fields need editLocked; lowering the rating needs
|
||||
* rateLower (and files rated general). Returns the product, and the history entry if anything changed.
|
||||
*/
|
||||
async updateInfo(
|
||||
byId: string,
|
||||
id: string,
|
||||
input: InfoInput,
|
||||
reason: string,
|
||||
rights: EditRights,
|
||||
revertOf?: number,
|
||||
): Promise<{ product: Product; entry?: number; fields: InfoField[] }> {
|
||||
const p = await this.editable(id, rights);
|
||||
reason = reason.trim().slice(0, 500);
|
||||
if (!rights.creator && !reason)
|
||||
throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason with the change.");
|
||||
const next: Partial<Pick<Product, InfoField>> = {};
|
||||
if (input.name !== undefined) {
|
||||
const name = nameRule(input.name, 64);
|
||||
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
|
||||
set.name = name;
|
||||
next.name = name;
|
||||
}
|
||||
if (input.description !== undefined) {
|
||||
if (input.description.length > maxDescription)
|
||||
throw new ProductError(400, "invalid", "That description is too long.");
|
||||
set.description = cleanHTML(input.description);
|
||||
next.description = cleanHTML(input.description);
|
||||
}
|
||||
if (input.rating !== undefined) {
|
||||
if (input.rating === "adult") set.rating = "adult";
|
||||
else if (input.rating === "general" && p.rating === "adult")
|
||||
// Lowering is reviewed by moderators first (design.md); until that exists, it's refused.
|
||||
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
|
||||
if (input.rating !== "general" && input.rating !== "adult")
|
||||
throw new ProductError(400, "invalid", "The rating must be general or adult.");
|
||||
next.rating = input.rating;
|
||||
}
|
||||
const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning();
|
||||
return updated!;
|
||||
if (input.tags !== undefined) {
|
||||
const tags = checkTags(input.tags);
|
||||
if (!tags)
|
||||
throw new ProductError(400, "invalid", "Up to 10 tags, each up to 24 letters, digits, spaces or dashes.");
|
||||
next.tags = tags;
|
||||
}
|
||||
if (input.listed !== undefined) next.listed = input.listed;
|
||||
if (input.category !== undefined) {
|
||||
if (input.category === null) next.category = null;
|
||||
else {
|
||||
const path = (await this.categoryChoices(p)).find((c) => c.at(-1)!.id === input.category);
|
||||
if (!path)
|
||||
throw new ProductError(400, "invalid", "Choose one of the categories offered for this product.");
|
||||
next.category = path;
|
||||
}
|
||||
}
|
||||
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
||||
for (const f of infoFields)
|
||||
if (next[f] !== undefined && !same(next[f], p[f])) changes[f] = { from: p[f], to: next[f] };
|
||||
const fields = Object.keys(changes) as InfoField[];
|
||||
if (fields.length === 0) return { product: p, fields };
|
||||
|
||||
const locked = fields.filter((f) => p.locked.includes(f));
|
||||
if (locked.length && !rights.editLocked)
|
||||
throw new ProductError(403, "locked", `A moderator locked this product's ${locked.join(", ")}.`);
|
||||
if (changes.rating?.to === "general") {
|
||||
if (!rights.rateLower)
|
||||
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
|
||||
const [adultFiles] = await this.db
|
||||
.select({ n: productRevision.number })
|
||||
.from(productRevision)
|
||||
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult"), eq(productRevision.retracted, false)))
|
||||
.limit(1);
|
||||
if (adultFiles)
|
||||
throw new ProductError(400, "files_adult", `Revision ${adultFiles.n}'s files are rated adult; retract it first.`);
|
||||
}
|
||||
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [updated] = await tx.update(product).set(next).where(eq(product.id, id)).returning();
|
||||
const [entry] = await tx
|
||||
.insert(productInfoChange)
|
||||
.values({ productId: id, byId, kind: revertOf ? "revert" : "edit", changes, reason, revertOf })
|
||||
.returning({ id: productInfoChange.id });
|
||||
return { product: updated!, entry: entry!.id, fields };
|
||||
});
|
||||
}
|
||||
|
||||
/** A draft becomes published: listed (unless unlisted), and people can get it. */
|
||||
async publish(byId: string, id: string) {
|
||||
const p = await this.editable(id, { creator: true, editLocked: false, rateLower: false });
|
||||
if (p.status !== "draft") throw new ProductError(409, "published", "It's published already.");
|
||||
if (!nameRule(p.name, 64)) throw new ProductError(400, "invalid", "Give it a name first.");
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
.update(product)
|
||||
.set({ status: "published", publishedAt: new Date() })
|
||||
.where(eq(product.id, id))
|
||||
.returning();
|
||||
await tx
|
||||
.insert(productInfoChange)
|
||||
.values({ productId: id, byId, kind: "publish", changes: { status: { from: "draft", to: "published" } } });
|
||||
return updated!;
|
||||
});
|
||||
}
|
||||
|
||||
/** Moderators lock info fields (the route checked product:lock); the reason is kept in the history. */
|
||||
async setLocks(byId: string, id: string, fields: string[], reason: string) {
|
||||
const p = await this.editable(id, { creator: false, editLocked: true, rateLower: true });
|
||||
const want = infoFields.filter((f) => fields.includes(f));
|
||||
if (want.length !== new Set(fields).size)
|
||||
throw new ProductError(400, "invalid", `Only these can be locked: ${infoFields.join(", ")}.`);
|
||||
reason = reason.trim().slice(0, 500);
|
||||
if (!reason) throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason.");
|
||||
if (JSON.stringify(want) === JSON.stringify(infoFields.filter((f) => p.locked.includes(f)))) return p;
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [updated] = await tx.update(product).set({ locked: want }).where(eq(product.id, id)).returning();
|
||||
await tx
|
||||
.insert(productInfoChange)
|
||||
.values({ productId: id, byId, kind: "lock", changes: { locked: { from: p.locked, to: want } }, reason });
|
||||
return updated!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories a product may be filed under (docs/products.md): every IMVU category path the hub has seen on a
|
||||
* revision's parent, and each step of those paths, under the same top-level category as its own parent's (any, if
|
||||
* its parent's is unknown). Keeping the top level keeps it the same kind of product (clothing, furniture, rooms).
|
||||
*/
|
||||
async categoryChoices(p: Product): Promise<CategoryPath[]> {
|
||||
const parent = (await this.latestParent(p))?.parentInfo?.categories ?? [];
|
||||
const seen = await this.db
|
||||
.selectDistinct({ path: sql<CategoryPath>`${productRevision.parentInfo}->'categories'` })
|
||||
.from(productRevision)
|
||||
.where(sql`jsonb_array_length(coalesce(${productRevision.parentInfo}->'categories', '[]'::jsonb)) > 0`);
|
||||
const byId = new Map<number, CategoryPath>();
|
||||
for (const path of [parent, ...seen.map((s) => s.path)])
|
||||
for (let i = 0; i < path.length; i++)
|
||||
if (!byId.has(path[i]!.id)) byId.set(path[i]!.id, path.slice(0, i + 1).map((c) => ({ id: c.id, name: c.name })));
|
||||
const top = parent[0]?.id;
|
||||
const label = (c: CategoryPath) => c.map((x) => x.name).join(" / ");
|
||||
return [...byId.values()].filter((c) => top === undefined || c[0]!.id === top).sort((a, b) => label(a).localeCompare(label(b)));
|
||||
}
|
||||
|
||||
/** A product's info history, newest first, with who made each change. */
|
||||
async history(id: string) {
|
||||
return this.db
|
||||
.select({
|
||||
id: productInfoChange.id,
|
||||
at: productInfoChange.createdAt,
|
||||
kind: productInfoChange.kind,
|
||||
changes: productInfoChange.changes,
|
||||
reason: productInfoChange.reason,
|
||||
revertOf: productInfoChange.revertOf,
|
||||
byId: productInfoChange.byId,
|
||||
by: user.displayUsername,
|
||||
})
|
||||
.from(productInfoChange)
|
||||
.leftJoin(user, eq(user.id, productInfoChange.byId))
|
||||
.where(eq(productInfoChange.productId, id))
|
||||
.orderBy(desc(productInfoChange.id));
|
||||
}
|
||||
|
||||
/** Puts back what a history entry changed, as a new entry (the same rules as an edit). */
|
||||
async revert(byId: string, id: string, entryId: number, reason: string, rights: EditRights) {
|
||||
const [e] = await this.db
|
||||
.select()
|
||||
.from(productInfoChange)
|
||||
.where(and(eq(productInfoChange.id, entryId), eq(productInfoChange.productId, id)));
|
||||
if (!e || (e.kind !== "edit" && e.kind !== "revert")) throw new ProductError(404, "not_found", "No such change to undo.");
|
||||
const input: Record<string, unknown> = {};
|
||||
for (const f of infoFields) if (e.changes[f]) input[f] = e.changes[f].from;
|
||||
// Categories are kept as paths; an edit takes the category's ID.
|
||||
if (Array.isArray(input.category)) input.category = (input.category as CategoryPath).at(-1)?.id ?? null;
|
||||
const done = await this.updateInfo(byId, id, input as InfoInput, reason, rights, e.id);
|
||||
if (done.fields.length === 0) throw new ProductError(409, "unchanged", "Its info is already as it was before that change.");
|
||||
return done;
|
||||
}
|
||||
|
||||
/** Deletes drafts never published within draftDays; their catalogs are told to delete the files. */
|
||||
async purgeDrafts(now = new Date()): Promise<number> {
|
||||
const before = new Date(now.getTime() - draftDays * 24 * 60 * 60 * 1000);
|
||||
const stale = await this.db
|
||||
.select({ id: product.id })
|
||||
.from(product)
|
||||
.where(and(eq(product.status, "draft"), sql`${product.createdAt} < ${before.toISOString()}::timestamptz`));
|
||||
for (const { id } of stale) {
|
||||
await this.db.transaction(async (tx) => {
|
||||
const hosted = await tx
|
||||
.select({ catalogId: hosting.catalogId, number: hosting.number, files: productRevision.files })
|
||||
.from(hosting)
|
||||
.innerJoin(productRevision, and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)))
|
||||
.where(eq(hosting.productId, id));
|
||||
for (const h of hosted)
|
||||
await tx
|
||||
.insert(catalogChange)
|
||||
.values({ catalogId: h.catalogId, data: { type: "delete", product: id, revision: h.number, files: h.files } });
|
||||
await tx.delete(hosting).where(eq(hosting.productId, id));
|
||||
await tx.delete(product).where(eq(product.id, id));
|
||||
});
|
||||
}
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Products for the console: a person's own, or with `all` (moderators) everyone's; drafts, unlisted, deleted and
|
||||
* removed ones included. `status`: draft, published, unlisted, deleted, removed or locked.
|
||||
*/
|
||||
async manage(opts: { creatorId?: string; q?: string; status?: string; limit?: number }) {
|
||||
const conds = [ne(product.latest, 0)];
|
||||
if (opts.creatorId) conds.push(eq(product.creatorId, opts.creatorId));
|
||||
if (opts.q) {
|
||||
const like = `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`;
|
||||
conds.push(or(ilike(product.name, like), eq(product.id, opts.q), ilike(user.username, like))!);
|
||||
}
|
||||
const live = and(isNull(product.deletedAt), isNull(product.removedAt))!;
|
||||
switch (opts.status) {
|
||||
case "draft":
|
||||
conds.push(eq(product.status, "draft"), live);
|
||||
break;
|
||||
case "published":
|
||||
conds.push(eq(product.status, "published"), eq(product.listed, true), live);
|
||||
break;
|
||||
case "unlisted":
|
||||
conds.push(eq(product.status, "published"), eq(product.listed, false), live);
|
||||
break;
|
||||
case "deleted":
|
||||
conds.push(sql`${product.deletedAt} is not null`, isNull(product.removedAt));
|
||||
break;
|
||||
case "removed":
|
||||
conds.push(sql`${product.removedAt} is not null`);
|
||||
break;
|
||||
case "locked":
|
||||
conds.push(sql`jsonb_array_length(${product.locked}) > 0`);
|
||||
break;
|
||||
}
|
||||
return this.db
|
||||
.select({
|
||||
product,
|
||||
creator: user.displayUsername,
|
||||
creatorUsername: user.username,
|
||||
parent: productRevision.parent,
|
||||
parentInfo: productRevision.parentInfo,
|
||||
libraries: sql<number>`(select count(*) from ${library} l where l.product_id = ${product.id})::int`,
|
||||
})
|
||||
.from(product)
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.leftJoin(productRevision, latestJoin)
|
||||
.where(and(...conds))
|
||||
.orderBy(desc(product.updatedAt))
|
||||
.limit(Math.max(1, Math.min(opts.limit ?? 100, 200)));
|
||||
}
|
||||
|
||||
/** How many drafts a person has (for the console's to-dos). */
|
||||
async draftCount(creatorId: string) {
|
||||
const [row] = await this.db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(product)
|
||||
.where(and(eq(product.creatorId, creatorId), eq(product.status, "draft"), isNull(product.deletedAt), ne(product.latest, 0)));
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
async retract(personId: string, id: string, number: number) {
|
||||
@@ -474,8 +781,9 @@ export class Products {
|
||||
return row;
|
||||
}
|
||||
|
||||
async add(personId: string, productId: string, adult: boolean) {
|
||||
const p = await this.get(productId, adult);
|
||||
async add(personId: string, productId: string, adult: boolean, viewer?: ProductViewer) {
|
||||
const p = await this.get(productId, adult, viewer);
|
||||
if (p.product.status !== "published") throw new ProductError(409, "draft", "This product isn't published yet.");
|
||||
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
|
||||
throw new ProductError(410, "deleted", "Its creator deleted this product.");
|
||||
if (!(await this.available(personId, productId)))
|
||||
@@ -524,8 +832,8 @@ export class Products {
|
||||
* The revision a person uses of a product: the one they stay on, else the latest; a retracted one gives way to
|
||||
* the nearest earlier one that isn't (or later, if none).
|
||||
*/
|
||||
async revisionFor(personId: string | undefined, productId: string, adult: boolean) {
|
||||
const p = await this.get(productId, adult);
|
||||
async revisionFor(personId: string | undefined, productId: string, adult: boolean, viewer?: ProductViewer) {
|
||||
const p = await this.get(productId, adult, viewer);
|
||||
const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined;
|
||||
const want = pin ?? p.product.latest;
|
||||
const usable = p.revisions.filter((r) => !r.retracted);
|
||||
|
||||
Reference in new issue
Block a user