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]>
156 lines
5.3 KiB
TypeScript
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;
|
|
}
|
|
}
|