feat: product drafts, moderator edits with reasons and locks, info history, unlisted products and tags
New uploads are drafts: only their creator and moderators see them, nobody can get or wear them, and they're deleted if not published within 30 days (their catalogs get delete changes). The creator reviews the info and publishes it. Editing a product's info is a permission check now (product:edit): moderators can edit anyone's with a reason the creator sees, lock fields (product:lock; locked fields need product:edit-locked) and lower ratings (product:rate-lower, not below its files'). Every change is kept in the product's info history, with who and why, and can be undone by the same rules. Products can be unlisted (out of search and lists, the link still works) and tagged; lists filter by tag and catalog. The console has a Products section (everyone's for moderators, by status) and a drafts to-do. A product's own category is left for later: the hub has no list of IMVU's categories to pick from. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2cbedd26fc
commit
8793b5afd8
24 files changed
+4106
-96
No files matched your search
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "product_info_change" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"product_id" text NOT NULL,
|
||||
"by_id" text,
|
||||
"kind" text NOT NULL,
|
||||
"changes" jsonb NOT NULL,
|
||||
"reason" text DEFAULT '' NOT NULL,
|
||||
"revert_of" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "status" text DEFAULT 'published' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "published_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "listed" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "tags" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "product" ADD COLUMN "locked" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "product_info_change" ADD CONSTRAINT "product_info_change_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "product_info_change" ADD CONSTRAINT "product_info_change_by_id_user_id_fk" FOREIGN KEY ("by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "product_info_change_product" ON "product_info_change" USING btree ("product_id","id");--> statement-breakpoint
|
||||
CREATE INDEX "product_tags" ON "product" USING gin ("tags");--> statement-breakpoint
|
||||
-- Products from before drafts were published when made.
|
||||
UPDATE "product" SET "published_at" = "created_at" WHERE "published_at" IS NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -78,6 +78,13 @@
|
||||
"when": 1793862017365,
|
||||
"tag": "0010_catalog_management",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1793884711638,
|
||||
"tag": "0011_product_info",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export { schema };
|
||||
export type { AuditTags, DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema";
|
||||
export type { AuditTags, DecorationItem, FileEntry, InfoChange, ParentInfo, RoomModerator, SceneItem } from "./schema";
|
||||
export * from "drizzle-orm";
|
||||
|
||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||
|
||||
@@ -359,6 +359,14 @@ export const product = pgTable(
|
||||
description: text("description").notNull().default(""), // HTML, already cleaned
|
||||
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
|
||||
latest: integer("latest").notNull().default(0), // the newest revision's number
|
||||
// "draft" (new uploads: only its creator and moderators see it) or "published".
|
||||
status: text("status").notNull().default("published"),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
// Unlisted: left out of search, lists and creator pages for others; its link still works.
|
||||
listed: boolean("listed").notNull().default(true),
|
||||
tags: jsonb("tags").$type<string[]>().notNull().default([]),
|
||||
// Info fields a moderator locked: only people with product:edit-locked change them.
|
||||
locked: jsonb("locked").$type<string[]>().notNull().default([]),
|
||||
// Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it.
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
// Removed by moderation: gone everywhere (no page, no files, out of libraries and rooms).
|
||||
@@ -367,7 +375,32 @@ export const product = pgTable(
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("product_creator").on(t.creatorId), index("product_created").on(t.createdAt)],
|
||||
(t) => [
|
||||
index("product_creator").on(t.creatorId),
|
||||
index("product_created").on(t.createdAt),
|
||||
index("product_tags").using("gin", t.tags),
|
||||
],
|
||||
);
|
||||
|
||||
/** One field's change in a product's info history. */
|
||||
export type InfoChange = { from: unknown; to: unknown };
|
||||
|
||||
// A product's info history: every edit, revert, lock and publish, with who and why (docs/products.md).
|
||||
export const productInfoChange = pgTable(
|
||||
"product_info_change",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
productId: text("product_id")
|
||||
.notNull()
|
||||
.references(() => product.id, { onDelete: "cascade" }),
|
||||
byId: text("by_id").references(() => user.id, { onDelete: "set null" }),
|
||||
kind: text("kind").notNull(), // "edit", "revert", "lock", "publish"
|
||||
changes: jsonb("changes").$type<Record<string, InfoChange>>().notNull(),
|
||||
reason: text("reason").notNull().default(""),
|
||||
revertOf: integer("revert_of"), // the entry a revert undid
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("product_info_change_product").on(t.productId, t.id)],
|
||||
);
|
||||
|
||||
// A revision: a product's files. Never changes (but can be retracted).
|
||||
|
||||
Reference in new issue
Block a user