Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ CHALLENGE_API_URL="https://api.topcoder-dev.com/v6/challenges/"
RESOURCE_API_URL="https://api.topcoder-dev.com/v6/"
V6_API_URL="https://api.topcoder-dev.com/v6"
MEMBER_API_URL="https://api.topcoder-dev.com/v6/members"
GROUPS_API_URL="https://api.topcoder-dev.com/v6/groups"

SUBMITTER_ROLE_ID="732339e7-8e30-49d7-9198-cccf9451e221"

Expand All @@ -66,6 +67,25 @@ MANUAL_UPLOAD_ALLOW_OPEN_SUBMISSION_PHASE=false
AWS_REGION="us-east-1"
# Force path-style addressing for some S3-compatible providers (e.g., MinIO)
S3_FORCE_PATH_STYLE=false
# Optional endpoint for local S3-compatible storage (for example MinIO)
S3_ENDPOINT=

# Required for Design submission previews: the existing Payload CMS media bucket
PAYLOAD_S3_BUCKET="payload-cms-assets-dev-811668436784"
# Payload media folder and public CloudFront origin
PAYLOAD_S3_PREFIX="media"
PAYLOAD_S3_PUBLIC_URL="https://assets.topcoder-dev.com"
PAYLOAD_S3_REGION="us-east-1"
# Optional child folder and bounded ZIP-processing limits
SUBMISSION_PREVIEW_S3_PREFIX="submission-previews"
SUBMISSION_PREVIEW_MAX_ARCHIVE_BYTES=262144000
SUBMISSION_PREVIEW_MAX_IMAGE_BYTES=10485760
SUBMISSION_PREVIEW_MAX_UNCOMPRESSED_BYTES=1073741824
SUBMISSION_PREVIEW_MAX_ZIP_ENTRIES=10000
SUBMISSION_PREVIEW_MAX_COMPRESSION_RATIO=100
SUBMISSION_PREVIEW_MAX_ATTEMPTS=5
SUBMISSION_PREVIEW_RETRY_BATCH_SIZE=10
SUBMISSION_PREVIEW_RECONCILE_BATCH_SIZE=25

# M2m configs
M2M_AUTH_URL="https://auth0.topcoder-dev.com/oauth/token"
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Topcoder Review API

## Opportunities experience additions

- [Design submission previews](docs/SUBMISSION_PREVIEWS.md) documents the
Screening-triggered extraction pipeline, release gates, S3 configuration,
retry behavior, and public redirect endpoint.
- [Review opportunity API](docs/REVIEW_OPPORTUNITIES.md) documents the
metadata-first search and current-member application endpoints used by
platform-ui.
- [`@topcoder/review-api-v6-prisma-client`](packages/review-prisma-client/README.md)
is the supported external Prisma client boundary for opportunities-api-v6.

Review API built on modern frameworks for managing all review-related Topcoder needs.

Use Node.js 26.5.0 (see `.nvmrc`) and pnpm 11.15.1 for local development and builds.
Expand Down Expand Up @@ -69,6 +80,7 @@ export POSTGRES_SCHEMA="prod_schema"
```

This allows you to:

- Use different schemas for different environments (development, testing, production)
- Isolate data for different instances of the application
- Improve security by separating schemas based on environment
Expand Down
119 changes: 119 additions & 0 deletions docs/REVIEW_OPPORTUNITIES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Review opportunities API

The opportunities UI can filter and page review work without downloading a
large client-side bucket. All routes below use the production `/v6` prefix.

## Search

`GET /review-opportunities/search` returns the standard response envelope:

```json
{
"result": {
"success": true,
"status": 200,
"content": [],
"metadata": {
"total": 0,
"offset": 0,
"limit": 10,
"page": 1,
"totalPages": 0
}
}
}
```

Supported query parameters are:

- `search`, repeated `challengeIds`;
- `paymentFrom`, `paymentTo`, `durationFrom`, `durationTo`, `startDateFrom`,
`startDateTo`, `numSubmissionsFrom`, and `numSubmissionsTo`;
- repeated challenge `tracks`/`track` and `types`/`type`, by UUID or catalog
name;
- repeated `opportunityTypes` values;
- repeated `status`/`statuses`; omission keeps the legacy `OPEN` default;
- `appliedByMe` and repeated `applicationStatuses`, which require a caller;
- `sortBy=basePayment|duration|startDate|openPositions`, `sortOrder`, `limit`
(maximum 100), and zero-based `offset`.

Challenge-backed filters run in the challenge database; pagination and totals
run in the review database after active-challenge visibility filtering. That
visibility boundary enforces both `ChallengeUserWhitelist` and challenge group
membership. Anonymous users receive only ungrouped challenges, ordinary
members receive public challenges plus their complete groups-api ancestor tree,
and a groups-api failure hides restricted records rather than exposing them.
Task challenges are hidden from anonymous callers and require the member to
have a `MemberChallengeAccess` resource; Admin and M2M callers retain their
operational access. A resource holder also retains access to an assigned
group-restricted challenge, matching challenge-api-v6 self-resource searches.

An `OPEN` review opportunity is returned only while its linked challenge is
`ACTIVE`. `CLOSED` and `CANCELLED` filters preserve historical opportunities
after the linked challenge completes.

`GET /review-opportunities` accepts the same query but preserves its historical
bare-array response. Pagination is returned in CORS-exposed `X-Total-Count`,
`X-Page`, `X-Per-Page`, and `X-Total-Pages` headers.

## Caller eligibility and applications

Every opportunity item adds:

- `canApply`;
- `canApplyReason`: `CAN_APPLY`, `NOT_AUTHENTICATED`, `NOT_REVIEWER`,
`OPPORTUNITY_CLOSED`, `CHALLENGE_NOT_ACTIVE`, `ALREADY_APPLIED`, or
`NO_OPEN_POSITIONS`;
- `myApplications`, containing only the caller's applications;
- `applicationCount`, the public-safe total across all application statuses;
- `approvedApplicationCount` and `remainingPositions`;
- `applicationRoles` and `defaultApplicationRole`, which let a one-click UI
submit the correct specialized role for regular, scenarios, iterative,
specification, or component-development review work.

Search/list responses include only the caller's application rows (or none for
anonymous callers). `applicationCount` and `approvedApplicationCount` are
separate database aggregate counts: the former is every application status and
the latter is only approved applications occupying capacity. This gives public
cards an accurate total without exposing applicant identity or downloading
every applicant. The single-opportunity and challenge-detail routes retain the
public applicant panel for the explicit detail click, but only after the linked
challenge passes the same whitelist, group, and task visibility boundary used
by search. Each panel resolves `openReviews` and 60-day
`latestCompletedReviews` totals in one batched query rather than returning
placeholder values or issuing one query per applicant.

`GET /review-applications/opportunity/{opportunityId}` remains compatible with
anonymous callers for visible public opportunities. It first resolves the
linked challenge and applies that same visibility boundary before querying any
application rows. A missing opportunity returns `404`; a caller denied by a
challenge whitelist, group restriction, or task assignment receives the same
`404` response, preventing opportunity-ID probing and never returning applicant
identities or metrics.

Challenge card data for a result page is hydrated with one batch projection;
detail-only phases, workflows, metadata, and winners are loaded only when a
specific opportunity is opened.

The embedded `challengeData` object includes `name`, the legacy `title` alias,
and the challenge's Markdown `description`. Its `overview` alias contains the
same Markdown so the opportunities detail page can render the full brief with
one review-api request.

Only the exact `Reviewer` role produces `CAN_APPLY`. This supports the UI rule
that non-reviewers receive a disabled action and the “How to become a reviewer”
content.

`GET /review-opportunities/me` is authenticated, forces `appliedByMe=true`, and
returns the metadata envelope. `GET /review-applications/me` supports repeated
`statuses`, repeated `roles`, `opportunityId`, `page`, `perPage` (maximum 100),
and `sortOrder`; its metadata is `total`, `page`, `perPage`, and `totalPages`.

`POST /review-applications` remains compatible with
`{ "opportunityId": "...", "role": "REVIEWER" }`, while now failing closed
for a closed opportunity, inactive or inaccessible challenge, duplicate
application, or filled approved capacity. The database composite uniqueness
constraint on opportunity, member, and role is authoritative for concurrent
duplicate requests; the losing request receives the same HTTP 409 conflict as
a duplicate found by the pre-check. Applications are created as `PENDING`, so
they do not consume or overfill the approved-position capacity.
104 changes: 104 additions & 0 deletions docs/SUBMISSION_PREVIEWS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Design submission previews

Review API extracts an optional root-level `preview.jpg` or `preview.png` from
a Design submission ZIP after the submission passes Screening. Checkpoint
submissions use a passing Checkpoint Screening result. Review completion
best-effort enqueues a candidate without depending on challenge-api; the
scheduled worker verifies the Design track and performs S3 and ZIP work. A
bounded state reconciler also discovers passing submissions with no queue row,
so a transient enqueue failure or pre-pipeline Screening result is recovered
without rolling back the completed review.

## Processing and idempotency

`submissionPreview` stores one job per submission, a random storage token,
attempt count, lease timestamps, source ETag, destination key, media metadata,
and the latest diagnostic. Repeated completed-review events are safe. Multiple
API replicas claim work through an atomic database update, and a stale
15-minute processing lease can be reclaimed after a worker terminates.

Transient failures use exponential backoff for up to
`SUBMISSION_PREVIEW_MAX_ATTEMPTS`. A ZIP without either supported root file is
recorded as `MISSING`; unsafe and structurally invalid archives become terminal
`FAILED` jobs. Each claimed batch is processed sequentially to cap temporary
disk, network, and decompression pressure. Operators can inspect `lastError`
for a stable code and detail.

Before each retry batch, the worker scans at most
`SUBMISSION_PREVIEW_RECONCILE_BATCH_SIZE` active file submissions whose matching
Screening review is completed and passing but whose preview job is absent. It
uses a duplicate-safe bulk insert, making continuous backfill safe across
multiple replicas. A reconciliation outage is logged but does not block retry
of already-durable jobs.

Run migration `20260813100000_add_submission_preview` before deploying workers.

## ZIP and image safety

The source URL must resolve to `SUBMISSION_CLEAN_S3_BUCKET`; DMZ, quarantine,
and arbitrary URLs are rejected. The worker writes the bounded source stream to
a private temporary file and never extracts a member-supplied path to disk. It
validates every central-directory entry before reading the preview:

- absolute, drive-prefixed, NUL, and parent-traversal paths are rejected;
- encrypted entries are rejected;
- entry count, total expanded bytes, per-entry compression ratio, archive size,
and preview size are bounded;
- duplicate preview candidates are rejected;
- only a root `preview.jpg` or `preview.png` is accepted;
- JPEG/PNG magic bytes must match the extension.

Temporary files use mode `0600` and are removed after every outcome.

## Storage configuration

The worker writes directly into the existing Payload media namespace. It does
not create a Payload `Media` document because submission previews are owned and
authorized by Review API rather than editorial content.

Required variables:

- `SUBMISSION_CLEAN_S3_BUCKET`
- `PAYLOAD_S3_BUCKET`
- `PAYLOAD_S3_PUBLIC_URL` (credential-free HTTPS origin)

Common optional variables and defaults:

- `PAYLOAD_S3_PREFIX=media`
- `SUBMISSION_PREVIEW_S3_PREFIX=submission-previews`
- `PAYLOAD_S3_REGION`, falling back to `AWS_REGION`
- `SUBMISSION_PREVIEW_RECONCILE_BATCH_SIZE=25` (maximum 250)
- the bounded size/count/retry variables listed in `.env.sample`

The ECS/task role needs `s3:GetObject` and `s3:HeadObject` on the clean
submission bucket, plus `s3:PutObject` on only the configured preview prefix in
the Payload bucket. The bucket policy should deny object listing to the public.
Uploaded keys contain a database-generated UUID that is not exposed before the
release gate.

## Public endpoint and release gate

`GET /v6/submissions/previews?challengeId={uuid}&page=1&perPage=20` is the
public-safe gallery endpoint used by Opportunities. It returns only released
preview cards as `{ data, meta: { page, perPage, totalCount, totalPages } }`;
each card includes the submission `id`, `type`, `submittedDate`, immutable
`previewUrl`, and a best-effort `submitterHandle`. It never exposes ZIP URLs,
scores, member email, queued jobs, missing preview state, or pre-release
screening outcomes. The protected general submission list remains unchanged.

`GET /v6/submissions/{submissionId}/preview` returns `302 Found` to the
immutable Payload asset URL only when all checks pass:

- the preview job is `READY`;
- the submission still has a completed, passing matching Screening review;
- the challenge is Design and visible to the caller under both the challenge
user whitelist and challenge group membership rules;
- a contest submission's `Review` phase has an `actualEndTime` in the past, or
a checkpoint submission's `Checkpoint Review` phase does.

Scheduled dates and `isOpen=false` alone do not release an image. Missing,
failed, unsupported, and not-yet-released states all return the same `404`
response with code `SUBMISSION_PREVIEW_NOT_AVAILABLE`, avoiding disclosure of
screening results. Whitelist denial returns `403`. The authorization redirect
is `private, no-store`; only the released asset URL uses immutable public
caching.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
"prisma": "6.19.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"tc-core-library-js": "topcoder-platform/tc-core-library-js#master"
"tc-core-library-js": "topcoder-platform/tc-core-library-js#master",
"yauzl": "3.2.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
Expand All @@ -70,6 +71,7 @@
"@types/multer": "^1.4.13",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"@types/yauzl": "2.10.3",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
Expand Down
41 changes: 41 additions & 0 deletions packages/review-prisma-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Review API v6 Prisma client

This directory is the supported database-client boundary for read-only service
consumers such as `opportunities-api-v6`. Its stable package name is
`@topcoder/review-api-v6-prisma-client`.

Install this directory as a workspace or file dependency, then import its stable
package name and pass the review database URL explicitly:

```json
{
"dependencies": {
"@topcoder/review-api-v6-prisma-client": "github:topcoder-platform/review-api-v6#<commit>&path:packages/review-prisma-client"
}
}
```

```ts
import { createReviewPrismaClient } from '@topcoder/review-api-v6-prisma-client';

const review = createReviewPrismaClient(process.env.REVIEW_DB_URL!);
const openCount = await review.reviewOpportunity.count({
where: { status: 'OPEN' },
});

await review.$disconnect();
```

The factory returns the generated Prisma client while hiding the Prisma 6
constructor's datasource option shape. Importing generated enums and types from
the same entry point is supported. Direct imports from `generated/` are exposed
for advanced consumers, but application code should prefer the factory.

The package declares the matching Prisma 6 runtime as a production dependency,
so GitHub-subdirectory and packed installations are self-contained. Keep that
version aligned with the generator used by review-api-v6 whenever Prisma is
upgraded.

Run `pnpm exec prisma generate` from the review-api-v6 repository root after
every schema change. Both the internal client and this checked-in external
client are generated from `prisma/schema.prisma`.
1 change: 1 addition & 0 deletions packages/review-prisma-client/generated/client.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./index"
5 changes: 5 additions & 0 deletions packages/review-prisma-client/generated/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

/* !!! This is code generated by Prisma. Do not edit directly. !!!
/* eslint-disable */
// biome-ignore-all lint: generated file
module.exports = { ...require('.') }
1 change: 1 addition & 0 deletions packages/review-prisma-client/generated/default.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./index"
5 changes: 5 additions & 0 deletions packages/review-prisma-client/generated/default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

/* !!! This is code generated by Prisma. Do not edit directly. !!!
/* eslint-disable */
// biome-ignore-all lint: generated file
module.exports = { ...require('#main-entry-point') }
1 change: 1 addition & 0 deletions packages/review-prisma-client/generated/edge.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./default"
Loading
Loading