A creator deletes a product: it's no longer listed and nobody new can get it, while people who have it keep it (its page, files and library entries stay, and rooms keep showing it), as old revisions stay available. Moderation removes one everywhere, with a reason: its page and files answer 410, and it leaves libraries and rooms. Both are on the product's page and in the audit log. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
07bbc11565
commit
97ce2be569
12 files changed
+2435
-14
No files matched your search
@@ -268,6 +268,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
parent: latest?.parent ?? 0,
|
parent: latest?.parent ?? 0,
|
||||||
image: latest?.parentInfo?.image ?? null,
|
image: latest?.parentInfo?.image ?? null,
|
||||||
categories: latest?.parentInfo?.categories ?? [],
|
categories: latest?.parentInfo?.categories ?? [],
|
||||||
|
deleted: p.deletedAt !== null,
|
||||||
createdAt: p.createdAt.toISOString(),
|
createdAt: p.createdAt.toISOString(),
|
||||||
updatedAt: p.updatedAt.toISOString(),
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
@@ -1155,6 +1156,26 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// Its creator deletes a product (no longer listed or added; those who have it keep it).
|
||||||
|
.delete(
|
||||||
|
"/products/:id",
|
||||||
|
async ({ me, params, set }) => {
|
||||||
|
await productCall(() => products.delete(me.id, params.id));
|
||||||
|
await audited(me.id, "product.delete", params.id);
|
||||||
|
set.status = 204;
|
||||||
|
},
|
||||||
|
{ signedIn: true, params: productParam },
|
||||||
|
)
|
||||||
|
// Moderation removes a product: gone everywhere.
|
||||||
|
.post(
|
||||||
|
"/products/:id/remove",
|
||||||
|
async ({ me, params, body }) => {
|
||||||
|
await productCall(() => products.removeByModeration(params.id, body.reason.trim()));
|
||||||
|
await audited(me.id, "product.remove", params.id, { reason: body.reason.trim() });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
{ moderator: true, params: productParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
|
||||||
|
)
|
||||||
.post(
|
.post(
|
||||||
"/products/:id/revisions/:n/retract",
|
"/products/:id/revisions/:n/retract",
|
||||||
async ({ me, params }) => {
|
async ({ me, params }) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the
|
// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the
|
||||||
// room is realtime.ts's decorated().
|
// room is realtime.ts's decorated().
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { and, eq, inArray, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
|
import { and, eq, inArray, isNull, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
|
||||||
import { hostedBase } from "./realtime";
|
import { hostedBase } from "./realtime";
|
||||||
|
|
||||||
const { roomDecoration, product, productRevision, imvuLink } = schema;
|
const { roomDecoration, product, productRevision, imvuLink } = schema;
|
||||||
@@ -55,6 +55,7 @@ export class Decorations {
|
|||||||
and(
|
and(
|
||||||
inArray(product.id, ids),
|
inArray(product.id, ids),
|
||||||
ne(product.latest, 0),
|
ne(product.latest, 0),
|
||||||
|
isNull(product.removedAt),
|
||||||
adult ? undefined : eq(product.rating, "general"),
|
adult ? undefined : eq(product.rating, "general"),
|
||||||
sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`,
|
sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`,
|
||||||
),
|
),
|
||||||
@@ -93,7 +94,7 @@ export class Decorations {
|
|||||||
const known = await this.db
|
const known = await this.db
|
||||||
.select({ id: product.id })
|
.select({ id: product.id })
|
||||||
.from(product)
|
.from(product)
|
||||||
.where(and(inArray(product.id, ids), ne(product.latest, 0)));
|
.where(and(inArray(product.id, ids), ne(product.latest, 0), isNull(product.removedAt)));
|
||||||
if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist.");
|
if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist.");
|
||||||
}
|
}
|
||||||
if (contents.length === 0) {
|
if (contents.length === 0) {
|
||||||
|
|||||||
@@ -296,6 +296,25 @@ export class Products {
|
|||||||
const [p] = await this.db.select().from(product).where(eq(product.id, productId));
|
const [p] = await this.db.select().from(product).where(eq(product.id, productId));
|
||||||
if (!p) throw new ProductError(404, "not_found", "No such product.");
|
if (!p) throw new ProductError(404, "not_found", "No such product.");
|
||||||
if (p.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
|
if (p.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
|
||||||
|
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
|
||||||
|
if (p.deletedAt) throw new ProductError(410, "deleted", "You deleted this product.");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its creator deletes a product: it's no longer listed or added to libraries (those who have it keep it). */
|
||||||
|
async delete(personId: string, id: string) {
|
||||||
|
await this.own(personId, id);
|
||||||
|
await this.db.update(product).set({ deletedAt: new Date() }).where(eq(product.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moderation removes a product: gone everywhere. */
|
||||||
|
async removeByModeration(id: string, reason: string) {
|
||||||
|
const [p] = await this.db
|
||||||
|
.update(product)
|
||||||
|
.set({ removedAt: new Date(), removedReason: reason })
|
||||||
|
.where(and(eq(product.id, id), isNull(product.removedAt)))
|
||||||
|
.returning();
|
||||||
|
if (!p) throw new ProductError(404, "not_found", "No such product, or it's removed already.");
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +323,7 @@ export class Products {
|
|||||||
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info.
|
* that IMVU category (anywhere in its path). Each comes with its latest revision's parent and parent info.
|
||||||
*/
|
*/
|
||||||
async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) {
|
async list(opts: { q?: string; creator?: string; category?: number; adult: boolean; limit?: number }) {
|
||||||
const conds = [ne(product.latest, 0)];
|
const conds = [ne(product.latest, 0), isNull(product.deletedAt), isNull(product.removedAt)];
|
||||||
if (!opts.adult) conds.push(eq(product.rating, "general"));
|
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.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.creator) conds.push(eq(user.username, opts.creator.toLowerCase()));
|
||||||
@@ -343,6 +362,7 @@ export class Products {
|
|||||||
.innerJoin(user, eq(user.id, product.creatorId))
|
.innerJoin(user, eq(user.id, product.creatorId))
|
||||||
.where(eq(product.id, id));
|
.where(eq(product.id, id));
|
||||||
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
|
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.rating === "adult" && !adult) 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
|
const revisions = await this.db
|
||||||
.select()
|
.select()
|
||||||
@@ -407,7 +427,7 @@ export class Products {
|
|||||||
.innerJoin(product, eq(product.id, library.productId))
|
.innerJoin(product, eq(product.id, library.productId))
|
||||||
.innerJoin(user, eq(user.id, product.creatorId))
|
.innerJoin(user, eq(user.id, product.creatorId))
|
||||||
.leftJoin(productRevision, latestJoin)
|
.leftJoin(productRevision, latestJoin)
|
||||||
.where(eq(library.userId, personId))
|
.where(and(eq(library.userId, personId), isNull(product.removedAt)))
|
||||||
.orderBy(desc(library.addedAt));
|
.orderBy(desc(library.addedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +440,9 @@ export class Products {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async add(personId: string, productId: string, adult: boolean) {
|
async add(personId: string, productId: string, adult: boolean) {
|
||||||
await this.get(productId, adult);
|
const p = await this.get(productId, adult);
|
||||||
|
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
|
||||||
|
throw new ProductError(410, "deleted", "Its creator deleted this product.");
|
||||||
await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
|
await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,3 +278,38 @@ test("an IMVU account's avatar picture is kept only from IMVU's own hosts", () =
|
|||||||
expect(user("https://evil.example/p.png").picture).toBeNull();
|
expect(user("https://evil.example/p.png").picture).toBeNull();
|
||||||
expect(user(undefined).picture).toBeNull();
|
expect(user(undefined).picture).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("creators delete products (those who have them keep them); moderation removes them everywhere", async () => {
|
||||||
|
const { admin, catalogID, auth } = shared;
|
||||||
|
const maker = await person("deleter", { id: 511, ageVerified: true });
|
||||||
|
const owner = await person("keeper", { id: 512, ageVerified: true });
|
||||||
|
const late = await person("latecomer", { id: 513, ageVerified: true });
|
||||||
|
const make = async (name: string) => {
|
||||||
|
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
|
||||||
|
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file(`${name}.xml`, 1)], rating: "general", parent: 500, name, notes: "" }, auth);
|
||||||
|
return r.json.product as string;
|
||||||
|
};
|
||||||
|
const listed = async () => (await call(undefined, "GET", "/api/v1/products?q=gone")).json.products.map((p: any) => p.name);
|
||||||
|
const id = await make("Gone Hat");
|
||||||
|
expect((await call(owner, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
|
||||||
|
|
||||||
|
// Only its creator deletes it; then it's not listed and nobody new adds it, but its page, the library entry and
|
||||||
|
// the files stay for those who have it.
|
||||||
|
expect((await call(owner, "DELETE", `/api/v1/products/${id}`)).status).toBe(403);
|
||||||
|
expect((await call(maker, "DELETE", `/api/v1/products/${id}`)).status).toBe(204);
|
||||||
|
expect(await listed()).toEqual([]);
|
||||||
|
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).json.product.deleted).toBe(true);
|
||||||
|
expect((await call(late, "PUT", `/api/v1/library/${id}`)).json.error).toBe("deleted");
|
||||||
|
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
|
||||||
|
expect((await call(owner, "GET", `/api/v1/products/${id}/files`)).status).toBe(200);
|
||||||
|
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Back" })).json.error).toBe("deleted");
|
||||||
|
|
||||||
|
// Moderation removes one: no page, no files, out of libraries.
|
||||||
|
const bad = await make("Gone Shoes");
|
||||||
|
expect((await call(owner, "PUT", `/api/v1/library/${bad}`)).status).toBe(200);
|
||||||
|
expect((await call(maker, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(403);
|
||||||
|
expect((await call(admin, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(200);
|
||||||
|
expect((await call(undefined, "GET", `/api/v1/products/${bad}`)).status).toBe(410);
|
||||||
|
expect((await call(owner, "GET", `/api/v1/products/${bad}/files`)).status).toBe(410);
|
||||||
|
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
|
||||||
|
});
|
||||||
@@ -6,8 +6,9 @@ import { PageHeader, Tag } from "@/components/status";
|
|||||||
import { DateTime } from "@/components/time";
|
import { DateTime } from "@/components/time";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { getMe, type Revision } from "@/lib/api";
|
import { Input } from "@/components/ui/input";
|
||||||
import { libraryAction, retract } from "../actions";
|
import { getMe, isModerator, type Revision } from "@/lib/api";
|
||||||
|
import { deleteProduct, libraryAction, removeProduct, retract } from "../actions";
|
||||||
import { loadProduct } from "../load";
|
import { loadProduct } from "../load";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
@@ -27,9 +28,16 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
|||||||
<span className="flex flex-wrap items-center gap-2">
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
{product.creator && <span>by {product.creator}</span>}
|
{product.creator && <span>by {product.creator}</span>}
|
||||||
<RatingTag rating={product.rating} />
|
<RatingTag rating={product.rating} />
|
||||||
|
{product.deleted && <Tag tone="danger">deleted</Tag>}
|
||||||
</span>
|
</span>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
{product.deleted && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{mine ? "You deleted this product." : "Its creator deleted this product."} People who have it keep it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{(product.image || product.categories.length > 0) && (
|
{(product.image || product.categories.length > 0) && (
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
{product.image && (
|
{product.image && (
|
||||||
@@ -44,7 +52,7 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{me ? (
|
{product.deleted && !inLibrary ? null : me ? (
|
||||||
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
|
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{inLibrary ? (
|
{inLibrary ? (
|
||||||
@@ -66,10 +74,21 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
|||||||
Sign in to get it
|
Sign in to get it
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{mine && (
|
{mine && !product.deleted && (
|
||||||
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
|
<>
|
||||||
Edit
|
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
|
||||||
</Link>
|
Edit
|
||||||
|
</Link>
|
||||||
|
<ActionForm action={deleteProduct} fields={{ id }}>
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
pendingLabel="Deleting…"
|
||||||
|
confirm="Delete this product? Nobody new can get it. People who have it keep it."
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,6 +101,26 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isModerator(me) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Moderation</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={removeProduct} fields={{ id }} className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
|
||||||
|
<SubmitButton
|
||||||
|
variant="destructive"
|
||||||
|
pendingLabel="Removing…"
|
||||||
|
confirm="Remove this product everywhere? Its page, files and library entries go."
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Revisions</CardTitle>
|
<CardTitle>Revisions</CardTitle>
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ export async function libraryAction(_: ActionState, form: FormData): Promise<Act
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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> {
|
export async function retract(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
const id = field(form, "id");
|
const id = field(form, "id");
|
||||||
return attempt(async () => {
|
return attempt(async () => {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import "server-only";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { api, ApiError, unwrap, type ProductPage } from "@/lib/api";
|
import { api, ApiError, unwrap, type ProductPage } from "@/lib/api";
|
||||||
|
|
||||||
/** The product, or not found (also when it's adult and the visitor can't see adult products). */
|
/** The product, or not found (also when it's adult and the visitor can't see adult products, or it was removed). */
|
||||||
export async function loadProduct(id: string): Promise<ProductPage> {
|
export async function loadProduct(id: string): Promise<ProductPage> {
|
||||||
if (!/^[a-z2-7]{10}$/.test(id)) notFound();
|
if (!/^[a-z2-7]{10}$/.test(id)) notFound();
|
||||||
try {
|
try {
|
||||||
return unwrap(await api.products({ id }).get());
|
return unwrap(await api.products({ id }).get());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 404) notFound();
|
if (e instanceof ApiError && (e.status === 404 || e.status === 410)) notFound();
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,8 @@ registering on the hub), `WEB_ORIGIN` (the hub website, for CORS).
|
|||||||
| `GET /api/v1/products/:id` | (anyone, adult rule as above) `{product, revisions, inLibrary?, mine?}` |
|
| `GET /api/v1/products/:id` | (anyone, adult rule as above) `{product, revisions, inLibrary?, mine?}` |
|
||||||
| `PATCH /api/v1/products/:id` | (creator) `{name?, description?, rating?}`; description is HTML, cleaned by the allowlist; rating may go general → adult |
|
| `PATCH /api/v1/products/:id` | (creator) `{name?, description?, rating?}`; description is HTML, cleaned by the allowlist; rating may go general → adult |
|
||||||
| `POST /api/v1/products/:id/revisions/:n/retract` | (creator) |
|
| `POST /api/v1/products/:id/revisions/:n/retract` | (creator) |
|
||||||
|
| `DELETE /api/v1/products/:id` | (creator) deletes it: no longer listed, and nobody new can get it; people who have it keep it (its page, files and library entries stay, and rooms keep showing it). Its creator can't edit it or add revisions after. 204 |
|
||||||
|
| `POST /api/v1/products/:id/remove` | (moderator) `{reason}`: removes it everywhere: its page and files answer 410 `removed`, and it leaves libraries and rooms |
|
||||||
| `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products, each with the revision to use |
|
| `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products, each with the revision to use |
|
||||||
| `PUT /api/v1/library/:id` | get a product (add it) |
|
| `PUT /api/v1/library/:id` | get a product (add it) |
|
||||||
| `DELETE /api/v1/library/:id` | remove it |
|
| `DELETE /api/v1/library/:id` | remove it |
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "product" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "product" ADD COLUMN "removed_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "product" ADD COLUMN "removed_reason" text;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,13 @@
|
|||||||
"when": 1793364727935,
|
"when": 1793364727935,
|
||||||
"tag": "0007_hosted_rooms_phase2",
|
"tag": "0007_hosted_rooms_phase2",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793372872749,
|
||||||
|
"tag": "0008_product_deletion",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -276,6 +276,11 @@ export const product = pgTable(
|
|||||||
description: text("description").notNull().default(""), // HTML, already cleaned
|
description: text("description").notNull().default(""), // HTML, already cleaned
|
||||||
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
|
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
|
latest: integer("latest").notNull().default(0), // the newest revision's number
|
||||||
|
// 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).
|
||||||
|
removedAt: timestamp("removed_at", { withTimezone: true }),
|
||||||
|
removedReason: text("removed_reason"),
|
||||||
createdAt: createdAt(),
|
createdAt: createdAt(),
|
||||||
updatedAt: updatedAt(),
|
updatedAt: updatedAt(),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user