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:
Zachary Lowden
2026-07-26 21:48:52 -05:00
committed by GitHub
parent 58faaf3ea1
commit 81fe8f7c90
17 changed files with 511 additions and 79 deletions
@@ -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(
+32 -1
View File
@@ -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>
+3 -3
View File
@@ -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' });
+37 -7
View File
@@ -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) => (
+34 -5
View File
@@ -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>
)}
+14 -1
View File
@@ -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"
>
+2 -2
View File
@@ -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).
+33 -8
View File
@@ -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 },