Files
t5de-hub/apps/web/app/products/actions.ts
T
dhkatz e07a6c6dac
Release / release (push) Successful in 13s
CI / test (push) Successful in 1m5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 4s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 16s
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]>
2026-11-05 14:23:26 +00:00

156 lines
5.3 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, ApiError, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const done = (id: string) => {
revalidatePath(`/products/${id}`);
revalidatePath("/library");
};
/** The person's library and a product: get it, remove it, stay on a revision or follow the latest. */
export async function libraryAction(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const lib = api.library({ id });
switch (field(form, "intent")) {
case "get":
unwrap(await lib.put());
break;
case "remove":
unwrap(await lib.delete());
break;
case "stay":
unwrap(await lib.patch({ stayOn: Number(field(form, "revision")) }));
break;
case "follow":
unwrap(await lib.patch({ stayOn: null }));
break;
}
done(id);
});
}
/** Its creator deletes the product. */
export async function deleteProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).delete());
done(id);
revalidatePath("/products");
});
if (state?.error) return state;
redirect(`/products/${id}`);
}
/** Moderation removes the product. */
export async function removeProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).remove.post({ reason: field(form, "reason") }));
done(id);
revalidatePath("/products");
});
if (state?.error) return state;
redirect("/products");
}
export async function retract(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
unwrap(await api.products({ id }).revisions({ n: Number(field(form, "revision")) }).retract.post());
done(id);
});
}
/** Saves a product's info. Fields left out of the form (locked ones are disabled) aren't changed. */
export async function saveProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const has = (name: string) => form.has(name);
const rating = field(form, "rating");
const listed = field(form, "listed");
return attempt(async () => {
const { fields } = unwrap(
await api.products({ id }).patch({
...(has("name") ? { name: field(form, "name") } : {}),
...(has("description") ? { description: String(form.get("description") ?? "") } : {}),
...(rating === "adult" || rating === "general" ? { rating } : {}),
...(has("tags")
? {
tags: field(form, "tags")
.split(",")
.map((t) => t.trim())
.filter(Boolean),
}
: {}),
...(listed === "yes" || listed === "no" ? { listed: listed === "yes" } : {}),
...(has("category") ? { category: Number(field(form, "category")) || null } : {}),
...(field(form, "reason") ? { reason: field(form, "reason") } : {}),
}),
);
done(id);
revalidatePath("/products");
revalidatePath(`/products/${id}/edit`);
return fields.length ? "Saved." : "Nothing changed.";
});
}
export async function publishProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).publish.post());
done(id);
revalidatePath("/products");
revalidatePath("/console/products");
});
if (state?.error) return state;
redirect(`/products/${id}`);
}
/** Moderators lock fields: the checked ones are locked, the rest unlocked. */
export async function saveLocks(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const fields = form
.getAll("locked")
.filter((v): v is "name" | "description" | "rating" | "tags" | "listed" | "category" =>
["name", "description", "rating", "tags", "listed", "category"].includes(String(v)),
);
return attempt(async () => {
unwrap(await api.products({ id }).locks.put({ fields, reason: field(form, "reason") }));
done(id);
revalidatePath(`/products/${id}/edit`);
return "Locks saved.";
});
}
/** Puts back what a history entry changed. */
export async function revertInfo(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const reason = field(form, "reason");
unwrap(
await api
.products({ id })
.history({ entry: Number(field(form, "entry")) })
.revert.post(reason ? { reason } : {}),
);
done(id);
revalidatePath(`/products/${id}/edit`);
return "Undone.";
});
}
/** An upload token and the catalog's upload address, for the browser to send the file there itself. */
export async function startUpload(
catalog: string,
product?: string,
): Promise<{ token: string; url: string } | { error: string }> {
try {
return unwrap(await api.products.upload.post({ catalog, ...(product ? { product } : {}) }));
} catch (e) {
if (e instanceof ApiError) return { error: e.message };
throw e;
}
}