mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
feat(app-listings): allow partial media submission (icon+cover floor, screenshots optional) (#3392)
* feat(app-listings): allow partial media submission (icon+cover floor, screenshots optional)
App Blocks store listings previously required icon + cover + >=1 screenshot to
submit AND to approve/publish. Screenshots need live-app capture, so owners
could not publish icon+cover now and add screenshots later.
Relax the hard gate to a minimum FLOOR: icon + cover REQUIRED, screenshots
OPTIONAL. A listing can now be submitted and go live with icon+cover and no
screenshots. This is a pure relaxation - nothing gets stricter, and no path that
had no gate gains one. Incompleteness stays surfaced (advisory) everywhere.
Server:
- New single-source floor helper in app-listing-assets.service.ts:
FLOOR_ASSETS, checkListingMeetsFloor, assertListingMeetsFloor (BAD_REQUEST,
lists missing icon/cover). checkListingAssetsComplete / assertListingAssetsComplete
are KEPT (unit-tested) as the advisory full-completeness source.
- Swapped the 4 hard-gate call sites in offsite-listing.service.ts from
assertListingAssetsComplete -> assertListingMeetsFloor: submitListingRevision,
approveExternalRequest (pre-tx + in-tx), applyApprovedRevision. Each site keeps
its existing {iconId, coverId, screenshotCount} arg shape and surrounding
primary-DB / URL-revalidate / no-mutation-on-throw logic.
- First-time external submit (submitExternalListing) is intentionally unchanged
(no submit-time asset gate; the floor still applies at approve).
Client tri-state:
- ListingAssetStep gates the submit affordance on meetsFloor (icon+cover) not
full completeness, exposes onCompletenessChange, and renders a tri-state alert:
below floor (warning), floor met but no screenshots (neutral, submit enabled),
complete (success). The /apps/[appBlockId]/listing "Submit for review" button
now disables only below the floor.
Surfacing (advisory, everywhere):
- Store card AppListingCard: OWNER-ONLY "Incomplete" badge when the card is below
floor (missing icon/cover). Public shoppers always see a normal card.
- Mod review (OffsiteReviewQueue): splits the assets-incomplete alert into a
blocking below-floor alert (missing icon/cover) and an advisory
missing-screenshots note, so a mod approves with eyes open.
- computeListingProblems (my-submissions): adds severity - icon/cover read as
blocking ("required before publishing"), screenshots/empty-text as advisory
("recommended"). Indicator colors red when below floor, yellow otherwise.
No DB migration (no schema change).
Tests: pure floor helpers + the distinctness regression; service submit/approve/
apply floor tests; the missing-SCREENSHOT blocked tests FLIPPED to succeed while
missing-icon/cover still block; listing-problems severity; and browser tests for
the tri-state alert, the floor-gated submit button, and the owner-only card
indicator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* polish(app-listings): stabilize onCompletenessChange callback + tighten floor-error assertions
- useCallback the onCompletenessChange handler on the listing-media page so
ListingAssetStep's effect (which deps on the callback) stops re-firing every
parent render (audit nit #3).
- Discriminate the below-floor approve tests on `missing: <asset>` instead of the
static prose, so the missing-icon vs missing-cover cases actually assert the
right asset (audit nit #2). Verified: 123 unit tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,41 @@ describe('AppListingCard', () => {
|
||||
await expect.element(page.getByTestId('apps-listing-owner-edit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('OWNER sees an "Incomplete" indicator when the card is below the floor (missing icon/cover)', async () => {
|
||||
mocks.currentUser = { id: 5, username: 'alice' }; // owner
|
||||
// base() has iconUrl: null + coverUrl: null → below floor.
|
||||
renderWithProviders(<AppListingCard card={base({})} canOpenPage />);
|
||||
await expect
|
||||
.element(page.getByTestId('apps-listing-owner-incomplete'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('OWNER does NOT see the "Incomplete" indicator when icon+cover are present', async () => {
|
||||
mocks.currentUser = { id: 5, username: 'alice' };
|
||||
renderWithProviders(
|
||||
<AppListingCard
|
||||
card={base({ iconUrl: 'https://edge/icon.png', coverUrl: 'https://edge/cover.png' })}
|
||||
canOpenPage
|
||||
/>
|
||||
);
|
||||
await expect.element(page.getByText('My App')).toBeInTheDocument();
|
||||
expect(page.getByTestId('apps-listing-owner-incomplete').elements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('NON-owner (public shopper) never sees the "Incomplete" indicator even below the floor', async () => {
|
||||
mocks.currentUser = { id: 999, username: 'bob' }; // not the creator
|
||||
renderWithProviders(<AppListingCard card={base({})} canOpenPage />);
|
||||
await expect.element(page.getByText('My App')).toBeInTheDocument();
|
||||
expect(page.getByTestId('apps-listing-owner-incomplete').elements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('signed-out viewer never sees the "Incomplete" indicator', async () => {
|
||||
mocks.currentUser = null;
|
||||
renderWithProviders(<AppListingCard card={base({})} canOpenPage />);
|
||||
await expect.element(page.getByText('My App')).toBeInTheDocument();
|
||||
expect(page.getByTestId('apps-listing-owner-incomplete').elements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a long username reveals the full value in a tooltip on hover (clip fallback)', async () => {
|
||||
const longName = 'a-really-long-creator-username-that-will-definitely-overflow-the-card-column';
|
||||
renderWithProviders(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Anchor, Avatar, Box, Button, Card, Group, Image, Stack, Text, Title } from '@mantine/core';
|
||||
import { Anchor, Avatar, Badge, Box, Button, Card, Group, Image, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { IconApps, IconExternalLink, IconPencil, IconThumbUp } from '@tabler/icons-react';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
@@ -163,6 +163,17 @@ export function AppListingCard({ card, canOpenPage = false }: AppListingCardProp
|
||||
const editHref = getOwnerEditHref(card.kindData, card.id);
|
||||
const showEdit = canOwnerEditListing({ isOwner }) && !!editHref;
|
||||
|
||||
// OWNER-ONLY incompleteness hint. The public store DTO carries only nullable
|
||||
// iconUrl/coverUrl (no screenshot count), so this is scoped to a below-floor
|
||||
// listing (missing icon or cover). A non-owner / public shopper always sees a
|
||||
// normal card (the cover placeholder already handles a missing cover). Small +
|
||||
// subtle by design — a nudge to the owner, never a public "broken" signal.
|
||||
const missingFloorAssets = [
|
||||
card.iconUrl == null ? 'icon' : null,
|
||||
card.coverUrl == null ? 'cover' : null,
|
||||
].filter((v): v is string => v != null);
|
||||
const showOwnerIncomplete = isOwner && missingFloorAssets.length > 0;
|
||||
|
||||
return (
|
||||
<Card shadow="sm" padding="md" radius="md" withBorder className="h-full">
|
||||
<ListingCover coverUrl={card.coverUrl} category={card.category} name={card.name} />
|
||||
@@ -196,6 +207,26 @@ export function AppListingCard({ card, canOpenPage = false }: AppListingCardProp
|
||||
</Title>
|
||||
</Anchor>
|
||||
<CreatorChip creator={card.creator} />
|
||||
{showOwnerIncomplete && (
|
||||
<Tooltip
|
||||
label={`Missing ${missingFloorAssets.join(' and ')} — add ${
|
||||
missingFloorAssets.length > 1 ? 'them' : 'it'
|
||||
} from Edit to complete your listing.`}
|
||||
withArrow
|
||||
multiline
|
||||
w={220}
|
||||
>
|
||||
<Badge
|
||||
color="yellow"
|
||||
variant="light"
|
||||
size="xs"
|
||||
style={{ cursor: 'help', alignSelf: 'flex-start' }}
|
||||
data-testid="apps-listing-owner-incomplete"
|
||||
>
|
||||
Incomplete
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -649,9 +649,9 @@ function ExternalCreateForm() {
|
||||
title="Draft created"
|
||||
>
|
||||
<Text size="sm">
|
||||
<Code>{submitted.slug}</Code> is a pending off-site submission. Attach an icon,
|
||||
a cover and at least one screenshot below — a moderator can only approve an
|
||||
asset-complete listing. Content rating:{' '}
|
||||
<Code>{submitted.slug}</Code> is a pending off-site submission. Attach an icon
|
||||
and a cover below to be approved — screenshots are recommended but optional and
|
||||
can be added later. Content rating:{' '}
|
||||
<Badge size="xs">{values.contentRating}</Badge>
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
@@ -308,6 +308,68 @@ describe('ListingAssetStep — uploaded-asset preview + cancel mid-scan', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('tri-state completeness alert + onCompletenessChange (partial-media floor)', async () => {
|
||||
// (a) BELOW FLOOR — icon prefilled, cover missing → warning copy, meetsFloor false.
|
||||
const belowFloor = { meetsFloor: null as boolean | null, complete: null as boolean | null };
|
||||
renderStep({
|
||||
initial: {
|
||||
icon: { imageId: 1, url: 'https://edge/icon.png' },
|
||||
cover: { imageId: null, url: null },
|
||||
screenshots: [],
|
||||
},
|
||||
onCompletenessChange: (s) => {
|
||||
belowFloor.meetsFloor = s.meetsFloor;
|
||||
belowFloor.complete = s.complete;
|
||||
},
|
||||
});
|
||||
const alert = page.getByTestId('apps-listing-assets-completeness');
|
||||
await expect.element(alert).toHaveTextContent(/Add an icon and cover to publish/i);
|
||||
await vi.waitFor(() => expect(belowFloor.meetsFloor).toBe(false));
|
||||
expect(belowFloor.complete).toBe(false);
|
||||
});
|
||||
|
||||
test('alert: FLOOR met but no screenshots → neutral "optional" copy, meetsFloor true', async () => {
|
||||
const state = { meetsFloor: null as boolean | null, complete: null as boolean | null };
|
||||
renderStep({
|
||||
initial: {
|
||||
icon: { imageId: 1, url: 'https://edge/icon.png' },
|
||||
cover: { imageId: 2, url: 'https://edge/cover.png' },
|
||||
screenshots: [],
|
||||
},
|
||||
onCompletenessChange: (s) => {
|
||||
state.meetsFloor = s.meetsFloor;
|
||||
state.complete = s.complete;
|
||||
},
|
||||
});
|
||||
const alert = page.getByTestId('apps-listing-assets-completeness');
|
||||
await expect
|
||||
.element(alert)
|
||||
.toHaveTextContent(/Screenshots are recommended but optional/i);
|
||||
await vi.waitFor(() => expect(state.meetsFloor).toBe(true));
|
||||
expect(state.complete).toBe(false);
|
||||
});
|
||||
|
||||
test('alert: fully complete (icon+cover+screenshot) → "All set." + complete true', async () => {
|
||||
const state = { meetsFloor: null as boolean | null, complete: null as boolean | null };
|
||||
renderStep({
|
||||
initial: {
|
||||
icon: { imageId: 1, url: 'https://edge/icon.png' },
|
||||
cover: { imageId: 2, url: 'https://edge/cover.png' },
|
||||
screenshots: [
|
||||
{ id: 'row-1', imageId: 3, url: 'https://edge/shot.png', caption: null, order: 0 },
|
||||
],
|
||||
},
|
||||
onCompletenessChange: (s) => {
|
||||
state.meetsFloor = s.meetsFloor;
|
||||
state.complete = s.complete;
|
||||
},
|
||||
});
|
||||
const alert = page.getByTestId('apps-listing-assets-completeness');
|
||||
await expect.element(alert).toHaveTextContent(/All set/i);
|
||||
await vi.waitFor(() => expect(state.complete).toBe(true));
|
||||
expect(state.meetsFloor).toBe(true);
|
||||
});
|
||||
|
||||
test('repeated upload + cancel does not crash or leak (blobs revoked each cycle)', async () => {
|
||||
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
|
||||
mocks.addScreenshotAsync.mockResolvedValue({ status: 'pending' });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Alert, Badge, Button, Card, FileInput, Group, Image, Loader, Stack, Tex
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconInfoCircle,
|
||||
IconPhoto,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
@@ -96,6 +97,7 @@ export function ListingAssetStep({
|
||||
footer,
|
||||
allowRemove = false,
|
||||
onAssetMutated,
|
||||
onCompletenessChange,
|
||||
}: {
|
||||
listingId: string;
|
||||
contentRating: OffsiteContentRating;
|
||||
@@ -111,6 +113,10 @@ export function ListingAssetStep({
|
||||
/** Called after any successful asset mutation (attach / remove) — edit mode uses
|
||||
* it to know a revision has diverged from the live listing. */
|
||||
onAssetMutated?: () => void;
|
||||
/** Called whenever the floor/completeness state changes so a caller can gate a
|
||||
* submit button. `meetsFloor` = icon+cover attached (the publish floor);
|
||||
* `complete` = also has ≥1 screenshot (advisory). */
|
||||
onCompletenessChange?: (state: { meetsFloor: boolean; complete: boolean }) => void;
|
||||
}) {
|
||||
const { uploadToCF } = useCFImageUpload();
|
||||
const [icon, setIcon] = useState<AssetState>(() => assetFromInitial(initial?.icon));
|
||||
@@ -426,8 +432,17 @@ export function ListingAssetStep({
|
||||
}
|
||||
|
||||
const attachedScreenshots = screenshots.filter((s) => s.status === 'attached').length;
|
||||
const complete =
|
||||
icon.status === 'attached' && cover.status === 'attached' && attachedScreenshots >= 1;
|
||||
// Publish FLOOR = icon + cover attached (screenshots OPTIONAL). A listing can be
|
||||
// submitted + published at the floor; screenshots are advisory/recommended.
|
||||
const meetsFloor = icon.status === 'attached' && cover.status === 'attached';
|
||||
// FULL completeness additionally requires ≥1 screenshot — advisory only now.
|
||||
const complete = meetsFloor && attachedScreenshots >= 1;
|
||||
|
||||
// Surface floor/completeness up to callers that gate a submit button (e.g. the
|
||||
// `/apps/[appBlockId]/listing` "Submit for review" button gates on `meetsFloor`).
|
||||
useEffect(() => {
|
||||
onCompletenessChange?.({ meetsFloor, complete });
|
||||
}, [meetsFloor, complete, onCompletenessChange]);
|
||||
|
||||
return (
|
||||
<Stack gap="md" data-testid="apps-offsite-submit-success">
|
||||
@@ -559,15 +574,30 @@ export function ListingAssetStep({
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Tri-state completeness advisory:
|
||||
(a) below floor (missing icon or cover) → warning, submit blocked;
|
||||
(b) floor met but no screenshots → neutral, submit ENABLED (optional);
|
||||
(c) fully complete → success. */}
|
||||
<Alert
|
||||
color={complete ? 'green' : 'yellow'}
|
||||
color={!meetsFloor ? 'red' : complete ? 'green' : 'blue'}
|
||||
variant="light"
|
||||
icon={complete ? <IconCheck size={16} /> : <IconAlertTriangle size={16} />}
|
||||
icon={
|
||||
!meetsFloor ? (
|
||||
<IconAlertTriangle size={16} />
|
||||
) : complete ? (
|
||||
<IconCheck size={16} />
|
||||
) : (
|
||||
<IconInfoCircle size={16} />
|
||||
)
|
||||
}
|
||||
data-testid="apps-listing-assets-completeness"
|
||||
>
|
||||
<Text size="sm">
|
||||
{complete
|
||||
? 'All required assets attached. Your submission is ready for moderator review.'
|
||||
: 'A moderator can only approve once an icon, a cover and ≥1 screenshot are attached.'}
|
||||
{!meetsFloor
|
||||
? 'Add an icon and cover to publish.'
|
||||
: complete
|
||||
? 'All set.'
|
||||
: 'Ready to publish. Screenshots are recommended but optional — you can add them later.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
import { HoverCard, List, Text, ThemeIcon } from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
|
||||
/** One advisory problem on a submission row (from the server's `computeListingProblems`). */
|
||||
export type ListingProblem = { code: string; label: string };
|
||||
/** One advisory problem on a submission row (from the server's `computeListingProblems`).
|
||||
* `severity` is optional for backward-compat: absent ⇒ treated as advisory. */
|
||||
export type ListingProblem = { code: string; label: string; severity?: 'blocking' | 'advisory' };
|
||||
|
||||
/**
|
||||
* Advisory "listing incomplete" warning shown next to a submission's status. When
|
||||
* the row has one or more problems (missing assets / empty key fields) it renders
|
||||
* an orange warning triangle; hovering (or focusing) opens a card that enumerates
|
||||
* each problem's label. Renders NOTHING when `problems` is empty — a clean row shows
|
||||
* no icon. Heads-up only; it is NOT a hard gate.
|
||||
* "Listing incomplete" warning shown next to a submission's status. When the row
|
||||
* has one or more problems (missing assets / empty key fields) it renders a warning
|
||||
* triangle; hovering (or focusing) opens a card that enumerates each problem's
|
||||
* label. The icon is RED when the listing is below the publish floor (a `blocking`
|
||||
* problem — missing icon/cover) and YELLOW when only advisory items remain
|
||||
* (screenshots / empty text). Renders NOTHING when `problems` is empty. Heads-up
|
||||
* only; it is NOT a hard gate.
|
||||
*/
|
||||
export function ListingProblemsIndicator({ problems }: { problems: ListingProblem[] }) {
|
||||
if (!problems || problems.length === 0) return null;
|
||||
const hasBlocking = problems.some((p) => p.severity === 'blocking');
|
||||
return (
|
||||
<HoverCard width={260} position="top" withArrow shadow="md" withinPortal openDelay={100}>
|
||||
<HoverCard width={280} position="top" withArrow shadow="md" withinPortal openDelay={100}>
|
||||
<HoverCard.Target>
|
||||
<ThemeIcon
|
||||
color="yellow"
|
||||
color={hasBlocking ? 'red' : 'yellow'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="xl"
|
||||
data-testid="apps-submission-problems"
|
||||
style={{ cursor: 'help' }}
|
||||
aria-label="This listing has problems"
|
||||
aria-label={
|
||||
hasBlocking ? 'This listing cannot be published yet' : 'This listing has advisory notes'
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconAlertTriangle size={14} />
|
||||
@@ -31,7 +37,7 @@ export function ListingProblemsIndicator({ problems }: { problems: ListingProble
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Text size="xs" fw={600} mb={4}>
|
||||
Listing needs attention
|
||||
{hasBlocking ? 'Required before publishing' : 'Recommended'}
|
||||
</Text>
|
||||
<List size="xs" spacing={2}>
|
||||
{problems.map((p) => (
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
IconExternalLink,
|
||||
IconFlag,
|
||||
IconHistory,
|
||||
IconInfoCircle,
|
||||
IconQuestionMark,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -389,7 +390,16 @@ export function OffsiteReviewModal({
|
||||
connectScopeJustifications: request.appListing?.connectScopeJustifications ?? null,
|
||||
});
|
||||
|
||||
const assetsIncomplete = !assetsQuery.isLoading && (!hasIcon || !hasCover || screenshotCount < 1);
|
||||
// Publish FLOOR = icon + cover (screenshots optional). Below-floor BLOCKS approve
|
||||
// (the server floor gate rejects it); missing screenshots is only ADVISORY — the
|
||||
// mod can approve, screenshots can be added later. Surface WHICH assets are
|
||||
// missing so the mod approves with eyes open.
|
||||
const missingFloor = !assetsQuery.isLoading
|
||||
? [!hasIcon ? 'icon' : null, !hasCover ? 'cover' : null].filter((v): v is string => v != null)
|
||||
: [];
|
||||
const belowFloor = missingFloor.length > 0;
|
||||
const missingScreenshotsOnly =
|
||||
!assetsQuery.isLoading && !belowFloor && screenshotCount < 1;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -559,11 +569,30 @@ export function OffsiteReviewModal({
|
||||
</List>
|
||||
</Stack>
|
||||
|
||||
{assetsIncomplete && (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||
{belowFloor && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
data-testid="apps-offsite-assets-below-floor"
|
||||
>
|
||||
<Text size="sm">
|
||||
Assets are incomplete — approve will be rejected by the server until an icon, a cover
|
||||
and ≥1 screenshot are attached.
|
||||
Missing: {missingFloor.join(', ')}. Approve will be rejected by the server until an
|
||||
icon and cover are attached (screenshots are optional).
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{missingScreenshotsOnly && (
|
||||
<Alert
|
||||
color="blue"
|
||||
variant="light"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
data-testid="apps-offsite-assets-missing-screenshots"
|
||||
>
|
||||
<Text size="sm">
|
||||
Missing: screenshots. This is optional — you can approve now; the author can add
|
||||
screenshots later.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Alert, Anchor, Button, Center, Container, Group, Loader, Stack, Text }
|
||||
import { IconArrowLeft, IconInfoCircle, IconSend } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { NotFound } from '~/components/AppLayout/NotFound';
|
||||
import { ListingAssetStep } from '~/components/Apps/ListingAssetStep';
|
||||
import { Meta } from '~/components/Meta/Meta';
|
||||
@@ -88,6 +88,17 @@ export default function ListingMediaPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listingId]);
|
||||
|
||||
// Track the asset floor so the submit button matches the server floor gate
|
||||
// (icon+cover required; screenshots optional). Defaults to false until the step
|
||||
// reports its state.
|
||||
const [meetsFloor, setMeetsFloor] = useState(false);
|
||||
// Stable identity so ListingAssetStep's onCompletenessChange effect (which lists
|
||||
// the callback in its deps) doesn't re-fire on every parent render.
|
||||
const handleCompletenessChange = useCallback(
|
||||
(state: { meetsFloor: boolean; complete: boolean }) => setMeetsFloor(state.meetsFloor),
|
||||
[]
|
||||
);
|
||||
|
||||
// 4) Submit the prepared shadow for moderator re-approval.
|
||||
const submitRevision = trpc.appListings.submitListingRevision.useMutation();
|
||||
async function handleSubmit() {
|
||||
@@ -176,12 +187,14 @@ export default function ListingMediaPage() {
|
||||
contentRating={listing.contentRating as OffsiteContentRating}
|
||||
suggestions={{}}
|
||||
allowRemove
|
||||
onCompletenessChange={handleCompletenessChange}
|
||||
/>
|
||||
</div>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={() => void handleSubmit()}
|
||||
loading={submitRevision.isPending}
|
||||
disabled={!meetsFloor}
|
||||
leftSection={<IconSend size={16} />}
|
||||
data-testid="apps-listing-media-submit"
|
||||
>
|
||||
|
||||
@@ -641,8 +641,8 @@ export const appListingsRouter = router({
|
||||
|
||||
/**
|
||||
* MOD: approve a pending off-site request (PR-b). Loads the request + its draft
|
||||
* listing, enforces `assertListingAssetsComplete` (THE P3 activation — approve
|
||||
* FAILS unless icon+cover+≥1 screenshot) + re-validates the stored externalUrl,
|
||||
* listing, enforces `assertListingMeetsFloor` (approve FAILS unless icon+cover;
|
||||
* screenshots are OPTIONAL — partial-media relaxation) + re-validates the stored externalUrl,
|
||||
* then flips the listing draft→approved + the request→approved (status-guarded)
|
||||
* and supersedes sibling pendings. v1 ALLOWS mod self-approve (reviewer ==
|
||||
* submitter — trusted, enables single-mod dogfood; a reviewer≠submitter
|
||||
|
||||
@@ -183,6 +183,74 @@ describe('checkListingAssetsComplete / assertListingAssetsComplete', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkListingMeetsFloor / assertListingMeetsFloor (icon+cover floor, screenshots optional)', () => {
|
||||
it('meets the floor with icon+cover and ZERO screenshots (the whole point)', async () => {
|
||||
const { checkListingMeetsFloor } = await import('../app-listing-assets.service');
|
||||
expect(checkListingMeetsFloor({ iconId: 1, coverId: 2, screenshotCount: 0 })).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports only the missing FLOOR assets (ignores screenshots)', async () => {
|
||||
const { checkListingMeetsFloor } = await import('../app-listing-assets.service');
|
||||
// Missing icon, has cover, 0 screenshots → only icon.
|
||||
expect(checkListingMeetsFloor({ iconId: null, coverId: 2, screenshotCount: 0 })).toEqual({
|
||||
ok: false,
|
||||
missing: ['icon'],
|
||||
});
|
||||
// Missing cover, has icon → only cover.
|
||||
expect(checkListingMeetsFloor({ iconId: 1, coverId: null, screenshotCount: 5 })).toEqual({
|
||||
ok: false,
|
||||
missing: ['cover'],
|
||||
});
|
||||
// Missing both.
|
||||
expect(checkListingMeetsFloor({ iconId: null, coverId: null, screenshotCount: 0 })).toEqual({
|
||||
ok: false,
|
||||
missing: ['icon', 'cover'],
|
||||
});
|
||||
});
|
||||
|
||||
it('assert throws BAD_REQUEST listing missing floor assets below floor', async () => {
|
||||
const { assertListingMeetsFloor } = await import('../app-listing-assets.service');
|
||||
const { TRPCError } = await import('@trpc/server');
|
||||
// Missing both → message lists both, code BAD_REQUEST.
|
||||
let thrown: unknown;
|
||||
try {
|
||||
assertListingMeetsFloor({ iconId: null, coverId: null, screenshotCount: 0 });
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(TRPCError);
|
||||
expect((thrown as InstanceType<typeof TRPCError>).code).toBe('BAD_REQUEST');
|
||||
expect((thrown as Error).message).toMatch(/icon, cover/);
|
||||
expect((thrown as Error).message).toMatch(/icon and cover/);
|
||||
// Missing only cover → message lists cover.
|
||||
expect(() => assertListingMeetsFloor({ iconId: 1, coverId: null, screenshotCount: 0 })).toThrow(
|
||||
/cover/
|
||||
);
|
||||
});
|
||||
|
||||
it('assert does NOT throw with icon+cover and 0 screenshots', async () => {
|
||||
const { assertListingMeetsFloor } = await import('../app-listing-assets.service');
|
||||
expect(() =>
|
||||
assertListingMeetsFloor({ iconId: 1, coverId: 2, screenshotCount: 0 })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('REGRESSION: checkListingAssetsComplete is UNCHANGED — icon+cover, 0 screenshots is still incomplete (advisory), proving the two helpers are distinct', async () => {
|
||||
const { checkListingAssetsComplete, checkListingMeetsFloor } = await import(
|
||||
'../app-listing-assets.service'
|
||||
);
|
||||
const listing = { iconId: 1, coverId: 2, screenshotCount: 0 };
|
||||
// Floor: passes. Full completeness: still missing screenshots.
|
||||
expect(checkListingMeetsFloor(listing)).toEqual({ ok: true });
|
||||
expect(checkListingAssetsComplete(listing)).toEqual({
|
||||
complete: false,
|
||||
missing: ['screenshots'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -117,4 +117,32 @@ describe('computeListingProblems', () => {
|
||||
'empty-tagline',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('severity — floor (icon/cover) is BLOCKING, everything else is ADVISORY', () => {
|
||||
const severityOf = (code: ListingProblemCode, input: ListingProblemInput) =>
|
||||
computeListingProblems(input).problems.find((p) => p.code === code)?.severity;
|
||||
|
||||
it('missing icon/cover are BLOCKING (required before publishing)', () => {
|
||||
expect(severityOf('missing-icon', { ...complete, iconId: null })).toBe('blocking');
|
||||
expect(severityOf('missing-cover', { ...complete, coverId: null })).toBe('blocking');
|
||||
});
|
||||
|
||||
it('missing screenshots is ADVISORY (recommended, optional)', () => {
|
||||
expect(severityOf('no-screenshots', { ...complete, screenshotCount: 0 })).toBe('advisory');
|
||||
});
|
||||
|
||||
it('empty text fields are ADVISORY', () => {
|
||||
expect(severityOf('empty-description', { ...complete, description: null })).toBe('advisory');
|
||||
expect(severityOf('empty-tagline', { ...complete, tagline: null })).toBe('advisory');
|
||||
expect(severityOf('empty-category', { ...complete, category: null })).toBe('advisory');
|
||||
});
|
||||
|
||||
it('icon/cover read as "required before publishing" and screenshots as "recommended"', () => {
|
||||
const iconLabel = computeListingProblems({ ...complete, iconId: null }).problems[0].label;
|
||||
expect(iconLabel).toMatch(/required before publishing/i);
|
||||
const shotLabel = computeListingProblems({ ...complete, screenshotCount: 0 }).problems[0]
|
||||
.label;
|
||||
expect(shotLabel).toMatch(/recommended|optional/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -536,12 +536,23 @@ describe('submitListingRevision', () => {
|
||||
expect(mockWrite.appListingPublishRequest.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asset-incomplete shadow (no screenshot) → BAD_REQUEST, no request', async () => {
|
||||
it('FLIPPED (partial-media): a shadow with icon+cover but NO screenshot now SUBMITS (screenshots optional)', async () => {
|
||||
// Was blocked by the full-completeness gate; the floor gate (icon+cover) lets a
|
||||
// screenshot-less revision submit. The shadow carries iconId+coverId.
|
||||
mockRead.appListing.findUnique.mockResolvedValue(shadowRow());
|
||||
mockWrite.appListingScreenshot.count.mockResolvedValue(0); // no real screenshot
|
||||
mockRead.appListingPublishRequest.findFirst.mockResolvedValue(null);
|
||||
const res = await submitListingRevision({ shadowId: 'apl_shadow', userId: OWNER });
|
||||
expect(res).toMatchObject({ shadowId: 'apl_shadow', slug: 'cool-app' });
|
||||
expect(mockWrite.appListingPublishRequest.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BELOW FLOOR: a shadow missing its cover → BAD_REQUEST, no request', async () => {
|
||||
mockRead.appListing.findUnique.mockResolvedValue(shadowRow({ coverId: null }));
|
||||
mockWrite.appListingScreenshot.count.mockResolvedValue(1);
|
||||
await expect(
|
||||
submitListingRevision({ shadowId: 'apl_shadow', userId: OWNER })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('screenshots') });
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('cover') });
|
||||
expect(mockWrite.appListingPublishRequest.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -713,12 +724,23 @@ describe('approveExternalRequest (revision apply)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('revision approve is BLOCKED if the shadow is asset-incomplete (primary re-assert)', async () => {
|
||||
it('FLIPPED (partial-media): revision approve with icon+cover but 0 screenshots now PUBLISHES (screenshots optional)', async () => {
|
||||
// Was blocked by the full-completeness gate; the floor gate (icon+cover) applies
|
||||
// a screenshot-less revision onto the live parent. The shadow has iconId+coverId.
|
||||
stageRevisionApprove();
|
||||
mockWrite.appListingScreenshot.count.mockResolvedValue(0); // no real screenshot on the shadow
|
||||
await expect(
|
||||
approveExternalRequest({ publishRequestId: 'alpr_rev', reviewerUserId: MOD })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('screenshots') });
|
||||
).resolves.toMatchObject({ listingId: 'apl_parent' });
|
||||
// The parent WAS copied (the apply proceeded).
|
||||
expect(mockWrite.appListing.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BELOW FLOOR: revision approve with a shadow missing its icon → BAD_REQUEST, no mutation (primary re-assert)', async () => {
|
||||
stageRevisionApprove({ iconId: null });
|
||||
await expect(
|
||||
approveExternalRequest({ publishRequestId: 'alpr_rev', reviewerUserId: MOD })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('icon') });
|
||||
// Neither the request nor the parent were mutated.
|
||||
expect(mockWrite.appListingPublishRequest.updateMany).not.toHaveBeenCalled();
|
||||
expect(mockWrite.appListing.update).not.toHaveBeenCalled();
|
||||
|
||||
@@ -724,7 +724,7 @@ describe('approveExternalRequest', () => {
|
||||
});
|
||||
await expect(
|
||||
approveExternalRequest({ publishRequestId: 'alpr_1', reviewerUserId: MOD })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('cover') });
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('missing: cover') });
|
||||
// We DID open the tx (the authoritative gate runs inside it) but bailed BEFORE
|
||||
// any flip — neither the request nor the listing status changed.
|
||||
expect(mockWrite.$transaction).toHaveBeenCalledTimes(1);
|
||||
@@ -732,16 +732,15 @@ describe('approveExternalRequest', () => {
|
||||
expect(mockWrite.appListing.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('REPLICA-LAG: replica shows a screenshot but the PRIMARY count is 0 → approve BLOCKED', async () => {
|
||||
it('FLIPPED (partial-media): PRIMARY screenshot count 0 with icon+cover still APPROVES (screenshots optional)', async () => {
|
||||
// The floor gate ignores the screenshot count entirely, so a primary count of 0
|
||||
// (icon+cover present) is no longer a block — the approve proceeds and flips.
|
||||
stageApproveScenario({ iconId: 1, coverId: 2, screenshotCount: 1 });
|
||||
mockWrite.appListingScreenshot.count.mockResolvedValue(0); // primary: no real screenshot
|
||||
await expect(
|
||||
approveExternalRequest({ publishRequestId: 'alpr_1', reviewerUserId: MOD })
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: expect.stringContaining('screenshots'),
|
||||
});
|
||||
expect(mockWrite.appListing.updateMany).not.toHaveBeenCalled();
|
||||
).resolves.toMatchObject({ listingId: 'apl_1' });
|
||||
expect(mockWrite.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supersedes any SIBLING pending request for the SAME LISTING (appListingId-scoped, Fix #2)', async () => {
|
||||
@@ -764,33 +763,33 @@ describe('approveExternalRequest', () => {
|
||||
expect(supersede.data).toEqual({ status: 'withdrawn' });
|
||||
});
|
||||
|
||||
it('BLOCKED by assertListingAssetsComplete — missing ICON → BAD_REQUEST, no mutation', async () => {
|
||||
it('BELOW FLOOR by assertListingMeetsFloor — missing ICON → BAD_REQUEST, no mutation', async () => {
|
||||
stageApproveScenario({ iconId: null, coverId: 2, screenshotCount: 1 });
|
||||
await expect(
|
||||
approveExternalRequest({ publishRequestId: 'alpr_1', reviewerUserId: MOD })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('icon') });
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('missing: icon') });
|
||||
// Missing on the replica too → fail-fast before the tx even opens.
|
||||
expect(mockWrite.$transaction).not.toHaveBeenCalled();
|
||||
expect(mockWrite.appListing.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BLOCKED by assertListingAssetsComplete — missing COVER → BAD_REQUEST, no mutation', async () => {
|
||||
it('BELOW FLOOR by assertListingMeetsFloor — missing COVER → BAD_REQUEST, no mutation', async () => {
|
||||
stageApproveScenario({ iconId: 1, coverId: null, screenshotCount: 1 });
|
||||
await expect(
|
||||
approveExternalRequest({ publishRequestId: 'alpr_1', reviewerUserId: MOD })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('cover') });
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('missing: cover') });
|
||||
expect(mockWrite.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BLOCKED by assertListingAssetsComplete — missing SCREENSHOT → BAD_REQUEST, no mutation', async () => {
|
||||
it('FLIPPED (partial-media): missing SCREENSHOT is now OPTIONAL — icon+cover, 0 screenshots APPROVES', async () => {
|
||||
// Was previously BLOCKED by the full-completeness gate; the floor gate
|
||||
// (icon+cover) lets a screenshot-less listing publish. This is the whole point.
|
||||
stageApproveScenario({ iconId: 1, coverId: 2, screenshotCount: 0 });
|
||||
await expect(
|
||||
approveExternalRequest({ publishRequestId: 'alpr_1', reviewerUserId: MOD })
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: expect.stringContaining('screenshots'),
|
||||
});
|
||||
expect(mockWrite.$transaction).not.toHaveBeenCalled();
|
||||
).resolves.toMatchObject({ listingId: 'apl_1' });
|
||||
// The approve proceeded into the transaction (the flip happened).
|
||||
expect(mockWrite.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('all assets present → the gate PASSES (approve proceeds); the count query excludes imageId-null rows', async () => {
|
||||
|
||||
@@ -82,8 +82,13 @@ export function checkListingAssetsComplete(
|
||||
}
|
||||
|
||||
/**
|
||||
* Throwing wrapper around {@link checkListingAssetsComplete} for the future
|
||||
* approve gate. NOT called on any live path in P1.
|
||||
* Throwing wrapper around {@link checkListingAssetsComplete}. ADVISORY-ONLY as of
|
||||
* the partial-media relaxation: no live path calls this any more — the live
|
||||
* submit/approve/apply gates use {@link assertListingMeetsFloor} (icon+cover floor,
|
||||
* screenshots optional). Kept exported + unit-tested as the full-completeness
|
||||
* assertion so the two helpers stay distinct and the completeness contract is
|
||||
* pinned; {@link checkListingAssetsComplete} remains the "what's still missing"
|
||||
* source for advisory surfacing (my-submissions problems / mod review).
|
||||
*/
|
||||
export function assertListingAssetsComplete(listing: ListingAssetCompleteness): void {
|
||||
const result = checkListingAssetsComplete(listing);
|
||||
@@ -95,6 +100,55 @@ export function assertListingAssetsComplete(listing: ListingAssetCompleteness):
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimum-FLOOR gate (icon + cover REQUIRED; screenshots OPTIONAL).
|
||||
//
|
||||
// This is the LIVE gate for submit + approve/apply as of the partial-media
|
||||
// relaxation: an owner can publish icon+cover now and add screenshots later.
|
||||
// Screenshots stay surfaced as advisory incompleteness (via
|
||||
// checkListingAssetsComplete), never a hard block. This is a pure relaxation of
|
||||
// the previous full-completeness gate — nothing gets stricter.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The assets a listing MUST have before it can be published. Screenshots are
|
||||
* deliberately excluded — they are advisory/optional. */
|
||||
export const FLOOR_ASSETS = ['icon', 'cover'] as const;
|
||||
|
||||
export type FloorAsset = (typeof FLOOR_ASSETS)[number];
|
||||
|
||||
export type ListingFloorResult = { ok: true } | { ok: false; missing: FloorAsset[] };
|
||||
|
||||
/**
|
||||
* Pure floor check: a listing meets the publish floor when it has an icon AND a
|
||||
* cover. Screenshots are ignored (optional). Returns the structured set of
|
||||
* missing FLOOR assets (never throws) so a caller can build a precise error.
|
||||
* Distinct from {@link checkListingAssetsComplete}, which additionally requires
|
||||
* ≥1 screenshot for FULL completeness (advisory).
|
||||
*/
|
||||
export function checkListingMeetsFloor(listing: ListingAssetCompleteness): ListingFloorResult {
|
||||
const missing: FloorAsset[] = [];
|
||||
if (listing.iconId == null) missing.push('icon');
|
||||
if (listing.coverId == null) missing.push('cover');
|
||||
return missing.length === 0 ? { ok: true } : { ok: false, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Throwing wrapper around {@link checkListingMeetsFloor}. This is the LIVE gate at
|
||||
* submit + approve/apply — throws BAD_REQUEST only when icon or cover is missing;
|
||||
* a listing with icon+cover but ZERO screenshots passes (screenshots optional).
|
||||
*/
|
||||
export function assertListingMeetsFloor(listing: ListingAssetCompleteness): void {
|
||||
const result = checkListingMeetsFloor(listing);
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Listing needs at least an icon and cover before it can be published (missing: ${result.missing.join(
|
||||
', '
|
||||
)}).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Off-site icon prefill helper (tiny pure helper; the off-site CREATION flow is
|
||||
// P3 — this only normalises an OauthClient.logoUrl into a usable http(s) URL).
|
||||
|
||||
@@ -41,15 +41,40 @@ export type ListingProblemCode =
|
||||
| 'empty-tagline'
|
||||
| 'empty-category';
|
||||
|
||||
export type ListingProblem = { code: ListingProblemCode; label: string };
|
||||
/**
|
||||
* A problem's severity relative to the publish FLOOR (icon + cover):
|
||||
* - `blocking` — below the floor; the listing CANNOT publish until fixed
|
||||
* (missing icon / cover).
|
||||
* - `advisory` — recommended but optional; does NOT block publish (missing
|
||||
* screenshots, empty description / tagline / category).
|
||||
*/
|
||||
export type ListingProblemSeverity = 'blocking' | 'advisory';
|
||||
|
||||
export type ListingProblem = {
|
||||
code: ListingProblemCode;
|
||||
label: string;
|
||||
severity: ListingProblemSeverity;
|
||||
};
|
||||
|
||||
export type ListingProblemsResult = { problems: ListingProblem[] };
|
||||
|
||||
/** Map the shared asset-gate `missing` codes → this surface's codes + labels. */
|
||||
/**
|
||||
* Map the shared asset-gate `missing` codes → this surface's codes + labels.
|
||||
* icon/cover are the publish FLOOR → `blocking` ("required before publishing");
|
||||
* screenshots are optional → `advisory` ("recommended").
|
||||
*/
|
||||
const ASSET_PROBLEM: Record<MissingAsset, ListingProblem> = {
|
||||
icon: { code: 'missing-icon', label: 'Missing icon' },
|
||||
cover: { code: 'missing-cover', label: 'Missing cover image' },
|
||||
screenshots: { code: 'no-screenshots', label: 'No screenshots' },
|
||||
icon: { code: 'missing-icon', label: 'Missing icon (required before publishing)', severity: 'blocking' },
|
||||
cover: {
|
||||
code: 'missing-cover',
|
||||
label: 'Missing cover image (required before publishing)',
|
||||
severity: 'blocking',
|
||||
},
|
||||
screenshots: {
|
||||
code: 'no-screenshots',
|
||||
label: 'No screenshots (recommended, optional)',
|
||||
severity: 'advisory',
|
||||
},
|
||||
};
|
||||
|
||||
/** A value is "empty" when it's null/undefined or trims to the empty string. */
|
||||
@@ -77,11 +102,11 @@ export function computeListingProblems(listing: ListingProblemInput): ListingPro
|
||||
}
|
||||
|
||||
if (isEmpty(listing.description))
|
||||
problems.push({ code: 'empty-description', label: 'Missing description' });
|
||||
problems.push({ code: 'empty-description', label: 'Missing description', severity: 'advisory' });
|
||||
if (isEmpty(listing.tagline))
|
||||
problems.push({ code: 'empty-tagline', label: 'Missing tagline' });
|
||||
problems.push({ code: 'empty-tagline', label: 'Missing tagline', severity: 'advisory' });
|
||||
if (isEmpty(listing.category))
|
||||
problems.push({ code: 'empty-category', label: 'Missing category' });
|
||||
problems.push({ code: 'empty-category', label: 'Missing category', severity: 'advisory' });
|
||||
|
||||
return { problems };
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SENSITIVE_TOKEN_SCOPES,
|
||||
tokenScopeMaskToList,
|
||||
} from '~/shared/constants/token-scope.constants';
|
||||
import { assertListingAssetsComplete } from '~/server/services/blocks/app-listing-assets.service';
|
||||
import { assertListingMeetsFloor } from '~/server/services/blocks/app-listing-assets.service';
|
||||
import { computeListingProblems } from '~/server/services/blocks/listing-problems';
|
||||
import { notifyAppListingOwner } from '~/server/services/blocks/app-listing-notify';
|
||||
import {
|
||||
@@ -1099,12 +1099,14 @@ export async function submitListingRevision(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
// Asset-completeness (authoritative on the primary — the asset mutators write to
|
||||
// dbWrite, so a replica count could be stale-complete under lag) + URL re-validate.
|
||||
// Publish FLOOR gate (icon+cover required; screenshots optional) — authoritative
|
||||
// on the primary (the asset mutators write to dbWrite, so a replica count could be
|
||||
// stale under lag). screenshotCount is still computed for the arg shape but the
|
||||
// floor helper ignores it. + URL re-validate.
|
||||
const screenshotCount = await dbWrite.appListingScreenshot.count({
|
||||
where: { appListingId: shadowId, imageId: { not: null } },
|
||||
});
|
||||
assertListingAssetsComplete({
|
||||
assertListingMeetsFloor({
|
||||
iconId: shadow.iconId,
|
||||
coverId: shadow.coverId,
|
||||
screenshotCount,
|
||||
@@ -1650,11 +1652,12 @@ function assertConnectSensitiveScopesJustified(listing: {
|
||||
* MOD approve of a pending off-site request. Loads the request + its draft
|
||||
* `AppListing`, asserts `pending`, and enforces two gates BEFORE any mutation:
|
||||
*
|
||||
* 1. {@link assertListingAssetsComplete} — **THE P3 ACTIVATION.** Approve FAILS
|
||||
* `BAD_REQUEST { missing }` unless the draft has an icon AND a cover AND ≥1
|
||||
* screenshot (a screenshot whose backing Image was deleted — `imageId` null
|
||||
* — does NOT count, mirroring `getListingAssets`' completeness math). This is
|
||||
* the intended live wiring of the dark P1 gate.
|
||||
* 1. {@link assertListingMeetsFloor} — the publish FLOOR gate. Approve FAILS
|
||||
* `BAD_REQUEST { missing }` unless the draft has an icon AND a cover.
|
||||
* Screenshots are OPTIONAL (a listing can go live with icon+cover and add
|
||||
* screenshots later); still-missing screenshots surface as advisory
|
||||
* incompleteness, never a block. This relaxed the former full-completeness
|
||||
* gate (which additionally required ≥1 screenshot) down to the floor.
|
||||
* 2. `validateExternalUrl` on the STORED `externalUrl` (defense-in-depth — a
|
||||
* somehow-bad stored value blocks approve; the card link opens in the user's
|
||||
* browser, so a non-https stored URL must never reach the store).
|
||||
@@ -1765,9 +1768,10 @@ export async function approveExternalRequest(opts: {
|
||||
where: { appListingId, imageId: { not: null } },
|
||||
});
|
||||
|
||||
// (3) THE P3 ACTIVATION — mandatory-asset gate (throws BAD_REQUEST { missing }).
|
||||
// Fail-fast copy on the replica; re-asserted authoritatively on the primary in (5).
|
||||
assertListingAssetsComplete({
|
||||
// (3) Publish FLOOR gate — icon+cover required, screenshots optional (throws
|
||||
// BAD_REQUEST { missing }). Fail-fast copy on the replica; re-asserted
|
||||
// authoritatively on the primary in (5).
|
||||
assertListingMeetsFloor({
|
||||
iconId: listing.iconId,
|
||||
coverId: listing.coverId,
|
||||
screenshotCount,
|
||||
@@ -1826,7 +1830,7 @@ export async function approveExternalRequest(opts: {
|
||||
const primaryScreenshotCount = await tx.appListingScreenshot.count({
|
||||
where: { appListingId, imageId: { not: null } },
|
||||
});
|
||||
assertListingAssetsComplete({
|
||||
assertListingMeetsFloor({
|
||||
iconId: primaryListing.iconId,
|
||||
coverId: primaryListing.coverId,
|
||||
screenshotCount: primaryScreenshotCount,
|
||||
@@ -2051,7 +2055,7 @@ async function applyApprovedRevision(opts: {
|
||||
const screenshotCount = await tx.appListingScreenshot.count({
|
||||
where: { appListingId: shadowId, imageId: { not: null } },
|
||||
});
|
||||
assertListingAssetsComplete({
|
||||
assertListingMeetsFloor({
|
||||
iconId: shadow.iconId,
|
||||
coverId: shadow.coverId,
|
||||
screenshotCount,
|
||||
@@ -2409,9 +2413,9 @@ const mySubmissionSelect = {
|
||||
// Filtered COUNT — only screenshots whose Image is still live. A row whose
|
||||
// Image was deleted (imageId → null via onDelete: SetNull) has no
|
||||
// displayable asset, so it must not inflate the count, else the
|
||||
// `no-screenshots` warning is a false-negative. Matches the authoritative
|
||||
// asset gate: `appListingScreenshot.count({ where: { imageId: { not: null } } })`
|
||||
// (see assertListingAssetsComplete callsite ~L1103 in this file).
|
||||
// `no-screenshots` (advisory) warning is a false-negative. Matches the
|
||||
// screenshot count query used elsewhere:
|
||||
// `appListingScreenshot.count({ where: { imageId: { not: null } } })`.
|
||||
_count: { select: { screenshots: { where: { imageId: { not: null } } } } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -31,6 +31,9 @@ const state = vi.hoisted(() => ({
|
||||
submit: { calls: [] as unknown[], pending: false },
|
||||
// Props the stubbed ListingAssetStep received.
|
||||
assetProps: { last: null as null | { listingId: string; contentRating: string } },
|
||||
// Floor state the stubbed step reports up via onCompletenessChange (drives the
|
||||
// submit button's disabled binding). Default meets-floor so the happy paths click.
|
||||
floor: { meetsFloor: true, complete: false },
|
||||
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||
flags: { appBlocks: true } as Record<string, boolean>,
|
||||
}));
|
||||
@@ -57,8 +60,14 @@ vi.mock('~/utils/notifications', () => ({
|
||||
// Stub the reused asset step — capture the props to prove the shell threads the
|
||||
// SHADOW id + rating, without re-running its own (covered) upload behaviour.
|
||||
vi.mock('~/components/Apps/ListingAssetStep', () => ({
|
||||
ListingAssetStep: (props: { listingId: string; contentRating: string }) => {
|
||||
ListingAssetStep: (props: {
|
||||
listingId: string;
|
||||
contentRating: string;
|
||||
onCompletenessChange?: (s: { meetsFloor: boolean; complete: boolean }) => void;
|
||||
}) => {
|
||||
state.assetProps.last = props;
|
||||
// Mirror the real step: report the floor state up so the page can gate submit.
|
||||
props.onCompletenessChange?.(state.floor);
|
||||
return (
|
||||
<div data-testid="asset-step">
|
||||
assets:{props.listingId}:{props.contentRating}
|
||||
@@ -114,6 +123,7 @@ beforeEach(() => {
|
||||
};
|
||||
state.begin = { shadowId: 'apl_shadow', error: null, pending: false };
|
||||
state.submit = { calls: [], pending: false };
|
||||
state.floor = { meetsFloor: true, complete: false };
|
||||
state.assetProps.last = null;
|
||||
state.invalidate.mockClear();
|
||||
state.flags = { appBlocks: true };
|
||||
@@ -147,6 +157,22 @@ describe('ListingMediaPage — owner listing-media route shell', () => {
|
||||
expect(state.submit.calls[0]).toEqual({ shadowId: 'apl_shadow' });
|
||||
});
|
||||
|
||||
test('the Submit button is ENABLED when the step reports it meets the floor (icon+cover)', async () => {
|
||||
state.floor = { meetsFloor: true, complete: false };
|
||||
renderWithProviders(<ListingMediaPage />);
|
||||
const submit = page.getByTestId('apps-listing-media-submit');
|
||||
await expect.element(submit).toBeInTheDocument();
|
||||
await expect.element(submit).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('the Submit button is DISABLED when the step reports it is below the floor', async () => {
|
||||
state.floor = { meetsFloor: false, complete: false };
|
||||
renderWithProviders(<ListingMediaPage />);
|
||||
const submit = page.getByTestId('apps-listing-media-submit');
|
||||
await expect.element(submit).toBeInTheDocument();
|
||||
await expect.element(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
test('shows the pending-revision notice when a revision is already under review', async () => {
|
||||
state.query = {
|
||||
data: { appListingId: 'apl_onsite', status: 'approved', contentRating: 'g', hasPendingRevision: true },
|
||||
|
||||
Reference in New Issue
Block a user