@commonpub/server 2.89.0 → 2.90.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/admin.d.ts.map +1 -1
- package/dist/admin/admin.js +4 -5
- package/dist/admin/admin.js.map +1 -1
- package/dist/content/content.d.ts +0 -5
- package/dist/content/content.d.ts.map +1 -1
- package/dist/content/content.js +118 -122
- package/dist/content/content.js.map +1 -1
- package/dist/content/index.d.ts +1 -1
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +1 -1
- package/dist/content/index.js.map +1 -1
- package/dist/contest/contest.d.ts +2 -308
- package/dist/contest/contest.d.ts.map +1 -1
- package/dist/contest/contest.js +87 -878
- package/dist/contest/contest.js.map +1 -1
- package/dist/contest/entries.d.ts +41 -0
- package/dist/contest/entries.d.ts.map +1 -0
- package/dist/contest/entries.js +285 -0
- package/dist/contest/entries.js.map +1 -0
- package/dist/contest/export.d.ts +20 -0
- package/dist/contest/export.d.ts.map +1 -0
- package/dist/contest/export.js +131 -0
- package/dist/contest/export.js.map +1 -0
- package/dist/contest/index.d.ts +11 -2
- package/dist/contest/index.d.ts.map +1 -1
- package/dist/contest/index.js +8 -1
- package/dist/contest/index.js.map +1 -1
- package/dist/contest/judges.js +9 -8
- package/dist/contest/judges.js.map +1 -1
- package/dist/contest/judging.d.ts +38 -0
- package/dist/contest/judging.d.ts.map +1 -0
- package/dist/contest/judging.js +274 -0
- package/dist/contest/judging.js.map +1 -0
- package/dist/contest/read.d.ts +44 -0
- package/dist/contest/read.d.ts.map +1 -0
- package/dist/contest/read.js +164 -0
- package/dist/contest/read.js.map +1 -0
- package/dist/contest/stages.d.ts +28 -0
- package/dist/contest/stages.d.ts.map +1 -0
- package/dist/contest/stages.js +52 -0
- package/dist/contest/stages.js.map +1 -0
- package/dist/contest/submissions.d.ts +90 -0
- package/dist/contest/submissions.d.ts.map +1 -0
- package/dist/contest/submissions.js +275 -0
- package/dist/contest/submissions.js.map +1 -0
- package/dist/contest/types.d.ts +197 -0
- package/dist/contest/types.d.ts.map +1 -0
- package/dist/contest/types.js +2 -0
- package/dist/contest/types.js.map +1 -0
- package/dist/contest/validation.d.ts +32 -0
- package/dist/contest/validation.d.ts.map +1 -0
- package/dist/contest/validation.js +132 -0
- package/dist/contest/validation.js.map +1 -0
- package/dist/docs/docs.d.ts +9 -3
- package/dist/docs/docs.d.ts.map +1 -1
- package/dist/docs/docs.js +16 -6
- package/dist/docs/docs.js.map +1 -1
- package/dist/events/events.d.ts.map +1 -1
- package/dist/events/events.js +12 -6
- package/dist/events/events.js.map +1 -1
- package/dist/federation/activityDedup.d.ts +14 -0
- package/dist/federation/activityDedup.d.ts.map +1 -0
- package/dist/federation/activityDedup.js +34 -0
- package/dist/federation/activityDedup.js.map +1 -0
- package/dist/federation/assertPublicHost.d.ts +14 -0
- package/dist/federation/assertPublicHost.d.ts.map +1 -0
- package/dist/federation/assertPublicHost.js +62 -0
- package/dist/federation/assertPublicHost.js.map +1 -0
- package/dist/federation/delivery.d.ts.map +1 -1
- package/dist/federation/delivery.js +37 -51
- package/dist/federation/delivery.js.map +1 -1
- package/dist/federation/federation.d.ts.map +1 -1
- package/dist/federation/federation.js +11 -7
- package/dist/federation/federation.js.map +1 -1
- package/dist/federation/hubMirroring.d.ts.map +1 -1
- package/dist/federation/hubMirroring.js +85 -66
- package/dist/federation/hubMirroring.js.map +1 -1
- package/dist/federation/inboxHandlers.d.ts.map +1 -1
- package/dist/federation/inboxHandlers.js +84 -73
- package/dist/federation/inboxHandlers.js.map +1 -1
- package/dist/federation/inboxParsing.d.ts +28 -0
- package/dist/federation/inboxParsing.d.ts.map +1 -0
- package/dist/federation/inboxParsing.js +71 -0
- package/dist/federation/inboxParsing.js.map +1 -0
- package/dist/federation/index.d.ts +2 -0
- package/dist/federation/index.d.ts.map +1 -1
- package/dist/federation/index.js +2 -0
- package/dist/federation/index.js.map +1 -1
- package/dist/federation/mastodonLogin.d.ts.map +1 -1
- package/dist/federation/mastodonLogin.js +19 -0
- package/dist/federation/mastodonLogin.js.map +1 -1
- package/dist/federation/outboxQueries.js +1 -1
- package/dist/federation/outboxQueries.js.map +1 -1
- package/dist/federation/timeline.d.ts +11 -0
- package/dist/federation/timeline.d.ts.map +1 -1
- package/dist/federation/timeline.js +101 -69
- package/dist/federation/timeline.js.map +1 -1
- package/dist/hub/hub.d.ts.map +1 -1
- package/dist/hub/hub.js +41 -3
- package/dist/hub/hub.js.map +1 -1
- package/dist/hub/index.d.ts +1 -1
- package/dist/hub/index.d.ts.map +1 -1
- package/dist/hub/index.js +1 -1
- package/dist/hub/index.js.map +1 -1
- package/dist/hub/members.d.ts +19 -0
- package/dist/hub/members.d.ts.map +1 -1
- package/dist/hub/members.js +158 -13
- package/dist/hub/members.js.map +1 -1
- package/dist/hub/moderation.d.ts +1 -1
- package/dist/hub/moderation.d.ts.map +1 -1
- package/dist/hub/moderation.js +25 -11
- package/dist/hub/moderation.js.map +1 -1
- package/dist/hub/posts.d.ts.map +1 -1
- package/dist/hub/posts.js +18 -10
- package/dist/hub/posts.js.map +1 -1
- package/dist/hub/resources.js +2 -2
- package/dist/hub/resources.js.map +1 -1
- package/dist/identity/mastodonFactory.d.ts.map +1 -1
- package/dist/identity/mastodonFactory.js +7 -0
- package/dist/identity/mastodonFactory.js.map +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/learning/learning.d.ts.map +1 -1
- package/dist/learning/learning.js +42 -22
- package/dist/learning/learning.js.map +1 -1
- package/dist/messaging/messaging.d.ts.map +1 -1
- package/dist/messaging/messaging.js +3 -3
- package/dist/messaging/messaging.js.map +1 -1
- package/dist/notification/notification.d.ts.map +1 -1
- package/dist/notification/notification.js +4 -2
- package/dist/notification/notification.js.map +1 -1
- package/dist/product/product.d.ts.map +1 -1
- package/dist/product/product.js +75 -37
- package/dist/product/product.js.map +1 -1
- package/dist/profile/index.d.ts +2 -1
- package/dist/profile/index.d.ts.map +1 -1
- package/dist/profile/index.js +1 -1
- package/dist/profile/index.js.map +1 -1
- package/dist/profile/profile.d.ts +24 -2
- package/dist/profile/profile.d.ts.map +1 -1
- package/dist/profile/profile.js +34 -8
- package/dist/profile/profile.js.map +1 -1
- package/dist/query.d.ts +18 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +24 -6
- package/dist/query.js.map +1 -1
- package/dist/rbac/seed.d.ts +1 -1
- package/dist/rbac/seed.d.ts.map +1 -1
- package/dist/rbac/seed.js +1 -0
- package/dist/rbac/seed.js.map +1 -1
- package/dist/search/contentSearch.d.ts +1 -1
- package/dist/search/contentSearch.d.ts.map +1 -1
- package/dist/search/contentSearch.js +22 -12
- package/dist/search/contentSearch.js.map +1 -1
- package/dist/social/social.d.ts +4 -2
- package/dist/social/social.d.ts.map +1 -1
- package/dist/social/social.js +25 -8
- package/dist/social/social.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/video/video.d.ts +3 -0
- package/dist/video/video.d.ts.map +1 -1
- package/dist/video/video.js +17 -13
- package/dist/video/video.js.map +1 -1
- package/dist/voting/voting.d.ts.map +1 -1
- package/dist/voting/voting.js +39 -1
- package/dist/voting/voting.js.map +1 -1
- package/package.json +10 -8
package/dist/contest/contest.js
CHANGED
|
@@ -1,187 +1,12 @@
|
|
|
1
|
-
import { eq,
|
|
2
|
-
import { contests, contestEntries, contestJudges, contestStakeholders
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* Stable ids let `currentStageId` reference them even for legacy contests.
|
|
11
|
-
*/
|
|
12
|
-
export function synthesizeStages(c) {
|
|
13
|
-
return [
|
|
14
|
-
{ id: 'core-submission', name: 'Submissions', kind: 'submission', core: true, startsAt: toIso(c.startDate), endsAt: toIso(c.endDate) },
|
|
15
|
-
{ id: 'core-review', name: 'Judging', kind: 'review', core: true, endsAt: toIso(c.judgingEndDate) ?? toIso(c.endDate) },
|
|
16
|
-
{ id: 'core-results', name: 'Results', kind: 'results', core: true },
|
|
17
|
-
];
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* The contest's stage timeline: its explicit `stages` if any are defined,
|
|
21
|
-
* otherwise the synthesized classic flow. The standard flow is the zero-config
|
|
22
|
-
* default — a contest with no `stages` renders identically to pre-B1.
|
|
23
|
-
*/
|
|
24
|
-
export function normalizeStages(c) {
|
|
25
|
-
return c.stages && c.stages.length > 0 ? c.stages : synthesizeStages(c);
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* The stage that is currently "now": the one `currentStageId` points at (if it
|
|
29
|
-
* resolves), else derived from the coarse `status`. Null while draft/cancelled
|
|
30
|
-
* (nothing is running). `status` remains the behavioural source of truth for
|
|
31
|
-
* gating; this is for DISPLAY (hero pill, sidebar highlight, countdown label).
|
|
32
|
-
*/
|
|
33
|
-
export function currentStage(c) {
|
|
34
|
-
const stages = normalizeStages(c);
|
|
35
|
-
if (c.currentStageId) {
|
|
36
|
-
const found = stages.find((s) => s.id === c.currentStageId);
|
|
37
|
-
if (found)
|
|
38
|
-
return found;
|
|
39
|
-
}
|
|
40
|
-
switch (c.status) {
|
|
41
|
-
case 'draft':
|
|
42
|
-
case 'cancelled':
|
|
43
|
-
return null;
|
|
44
|
-
case 'completed':
|
|
45
|
-
return stages.find((s) => s.kind === 'results') ?? stages[stages.length - 1] ?? null;
|
|
46
|
-
case 'judging':
|
|
47
|
-
return stages.find((s) => s.kind === 'review') ?? null;
|
|
48
|
-
default: // upcoming | active | paused
|
|
49
|
-
return stages.find((s) => s.kind === 'submission') ?? stages[0] ?? null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
export async function listContests(db, filters = {}, viewer) {
|
|
53
|
-
const conditions = [];
|
|
54
|
-
if (filters.status) {
|
|
55
|
-
conditions.push(eq(contests.status, filters.status));
|
|
56
|
-
}
|
|
57
|
-
// Visibility: admins see everything. Everyone else sees `public` contests,
|
|
58
|
-
// plus — when signed in — the ones they have a relationship to so they're not
|
|
59
|
-
// hidden in the listing: their own, ones they review (stakeholder), ones they
|
|
60
|
-
// judge, and private ones whose `visibleToRoles` includes their role. (`unlisted`
|
|
61
|
-
// stays link-only; mirrors canViewContest so the listing matches per-contest access.)
|
|
62
|
-
if (viewer?.role !== 'admin') {
|
|
63
|
-
const visConds = [eq(contests.visibility, 'public')];
|
|
64
|
-
if (viewer?.userId) {
|
|
65
|
-
visConds.push(eq(contests.createdById, viewer.userId));
|
|
66
|
-
visConds.push(inArray(contests.id, db.select({ id: contestStakeholders.contestId }).from(contestStakeholders).where(eq(contestStakeholders.userId, viewer.userId))));
|
|
67
|
-
visConds.push(inArray(contests.id, db.select({ id: contestJudges.contestId }).from(contestJudges).where(eq(contestJudges.userId, viewer.userId))));
|
|
68
|
-
}
|
|
69
|
-
if (viewer?.role) {
|
|
70
|
-
visConds.push(sql `${contests.visibleToRoles} @> ${JSON.stringify([viewer.role])}::jsonb`);
|
|
71
|
-
}
|
|
72
|
-
conditions.push(visConds.length > 1 ? or(...visConds) : visConds[0]);
|
|
73
|
-
// Drafts never appear in listings except to their own owner (admins, handled
|
|
74
|
-
// above, see everything). Orthogonal to visibility — a public draft is still hidden.
|
|
75
|
-
conditions.push(viewer?.userId
|
|
76
|
-
? or(ne(contests.status, 'draft'), eq(contests.createdById, viewer.userId))
|
|
77
|
-
: ne(contests.status, 'draft'));
|
|
78
|
-
}
|
|
79
|
-
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
80
|
-
const { limit, offset } = normalizePagination(filters);
|
|
81
|
-
const [rows, total] = await Promise.all([
|
|
82
|
-
db
|
|
83
|
-
.select()
|
|
84
|
-
.from(contests)
|
|
85
|
-
.where(where)
|
|
86
|
-
.orderBy(desc(contests.startDate))
|
|
87
|
-
.limit(limit)
|
|
88
|
-
.offset(offset),
|
|
89
|
-
countRows(db, contests, where),
|
|
90
|
-
]);
|
|
91
|
-
const items = rows.map((row) => ({
|
|
92
|
-
id: row.id,
|
|
93
|
-
title: row.title,
|
|
94
|
-
slug: row.slug,
|
|
95
|
-
subheading: row.subheading,
|
|
96
|
-
description: row.description,
|
|
97
|
-
bannerUrl: row.bannerUrl,
|
|
98
|
-
coverImageUrl: row.coverImageUrl,
|
|
99
|
-
status: row.status,
|
|
100
|
-
startDate: row.startDate,
|
|
101
|
-
endDate: row.endDate,
|
|
102
|
-
entryCount: row.entryCount,
|
|
103
|
-
createdAt: row.createdAt,
|
|
104
|
-
}));
|
|
105
|
-
return { items, total };
|
|
106
|
-
}
|
|
107
|
-
function toContestDetail(row) {
|
|
108
|
-
return {
|
|
109
|
-
id: row.id,
|
|
110
|
-
title: row.title,
|
|
111
|
-
slug: row.slug,
|
|
112
|
-
description: row.description,
|
|
113
|
-
bannerUrl: row.bannerUrl,
|
|
114
|
-
coverImageUrl: row.coverImageUrl,
|
|
115
|
-
status: row.status,
|
|
116
|
-
startDate: row.startDate,
|
|
117
|
-
endDate: row.endDate,
|
|
118
|
-
entryCount: row.entryCount,
|
|
119
|
-
createdAt: row.createdAt,
|
|
120
|
-
subheading: row.subheading,
|
|
121
|
-
rules: row.rules,
|
|
122
|
-
prizesDescription: row.prizesDescription,
|
|
123
|
-
descriptionFormat: row.descriptionFormat,
|
|
124
|
-
rulesFormat: row.rulesFormat,
|
|
125
|
-
prizesDescriptionFormat: row.prizesDescriptionFormat,
|
|
126
|
-
showPrizes: row.showPrizes,
|
|
127
|
-
stages: row.stages ?? [],
|
|
128
|
-
currentStageId: row.currentStageId ?? null,
|
|
129
|
-
prizes: row.prizes ?? null,
|
|
130
|
-
judgingCriteria: row.judgingCriteria ?? null,
|
|
131
|
-
judgingVisibility: row.judgingVisibility,
|
|
132
|
-
judgingEndDate: row.judgingEndDate,
|
|
133
|
-
communityVotingEnabled: row.communityVotingEnabled,
|
|
134
|
-
eligibleContentTypes: row.eligibleContentTypes ?? null,
|
|
135
|
-
maxEntriesPerUser: row.maxEntriesPerUser ?? null,
|
|
136
|
-
visibility: row.visibility,
|
|
137
|
-
visibleToRoles: row.visibleToRoles ?? null,
|
|
138
|
-
createdById: row.createdById,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Whether `user` may view this contest. `public`/`unlisted` are viewable by
|
|
143
|
-
* anyone (unlisted is simply hidden from listings). `private` is restricted to
|
|
144
|
-
* the owner, admins, stakeholders, panel judges, and users whose role is in
|
|
145
|
-
* `visibleToRoles`.
|
|
146
|
-
*/
|
|
147
|
-
export async function canViewContest(db, contest, user) {
|
|
148
|
-
// Drafts are owner-only regardless of the visibility setting — an unlaunched
|
|
149
|
-
// contest must never be world-readable, even when its visibility is `public`.
|
|
150
|
-
if (contest.status === 'draft') {
|
|
151
|
-
if (!user)
|
|
152
|
-
return false;
|
|
153
|
-
if (user.id === contest.createdById || user.role === 'admin')
|
|
154
|
-
return true;
|
|
155
|
-
if (await isContestStakeholder(db, contest.id, user.id))
|
|
156
|
-
return true;
|
|
157
|
-
if (await isContestJudge(db, contest.id, user.id))
|
|
158
|
-
return true;
|
|
159
|
-
return false;
|
|
160
|
-
}
|
|
161
|
-
if (contest.visibility !== 'private')
|
|
162
|
-
return true;
|
|
163
|
-
if (!user)
|
|
164
|
-
return false;
|
|
165
|
-
if (user.id === contest.createdById || user.role === 'admin')
|
|
166
|
-
return true;
|
|
167
|
-
if (contest.visibleToRoles && contest.visibleToRoles.includes(user.role))
|
|
168
|
-
return true;
|
|
169
|
-
if (await isContestStakeholder(db, contest.id, user.id))
|
|
170
|
-
return true;
|
|
171
|
-
if (await isContestJudge(db, contest.id, user.id))
|
|
172
|
-
return true;
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
export async function getContestBySlug(db, slug) {
|
|
176
|
-
const rows = await db
|
|
177
|
-
.select()
|
|
178
|
-
.from(contests)
|
|
179
|
-
.where(eq(contests.slug, slug))
|
|
180
|
-
.limit(1);
|
|
181
|
-
if (rows.length === 0)
|
|
182
|
-
return null;
|
|
183
|
-
return toContestDetail(rows[0]);
|
|
184
|
-
}
|
|
1
|
+
import { eq, and, isNotNull } from 'drizzle-orm';
|
|
2
|
+
import { contests, contestEntries, contestJudges, contestStakeholders } from '@commonpub/schema';
|
|
3
|
+
import { isContestEditor } from './stakeholders.js';
|
|
4
|
+
import { toContestDetail, getContestBySlug } from './read.js';
|
|
5
|
+
import { calculateContestRanks } from './entries.js';
|
|
6
|
+
// Contest CRUD + lifecycle. The read/listing path lives in entries.ts, the pure
|
|
7
|
+
// stage helpers in stages.ts, judging/advancement in judging.ts, and the
|
|
8
|
+
// per-stage submission + proposal flows in submissions.ts. This module owns the
|
|
9
|
+
// create/update/delete writers and the status state machine.
|
|
185
10
|
/**
|
|
186
11
|
* Check if a user role is allowed to create contests based on the instance policy.
|
|
187
12
|
*
|
|
@@ -203,54 +28,63 @@ export async function createContest(db, input, options) {
|
|
|
203
28
|
throw new Error(`Insufficient permissions: contest creation requires ${policy} role`);
|
|
204
29
|
}
|
|
205
30
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
31
|
+
// Atomic: the contest row and its seeded judges/stakeholders must commit
|
|
32
|
+
// together, so a failed seed (e.g. a bad judge id) can't leave a contest
|
|
33
|
+
// missing the judges/reviewers the organizer asked for.
|
|
34
|
+
const row = await db.transaction(async (tx) => {
|
|
35
|
+
const [inserted] = await tx
|
|
36
|
+
.insert(contests)
|
|
37
|
+
.values({
|
|
38
|
+
title: input.title,
|
|
39
|
+
slug: input.slug,
|
|
40
|
+
subheading: input.subheading ?? null,
|
|
41
|
+
description: input.description ?? null,
|
|
42
|
+
rules: input.rules ?? null,
|
|
43
|
+
prizesDescription: input.prizesDescription ?? null,
|
|
44
|
+
descriptionFormat: input.descriptionFormat ?? 'markdown',
|
|
45
|
+
rulesFormat: input.rulesFormat ?? 'markdown',
|
|
46
|
+
prizesDescriptionFormat: input.prizesDescriptionFormat ?? 'markdown',
|
|
47
|
+
descriptionBlocks: input.descriptionBlocks ?? null,
|
|
48
|
+
rulesBlocks: input.rulesBlocks ?? null,
|
|
49
|
+
prizesBlocks: input.prizesBlocks ?? null,
|
|
50
|
+
showPrizes: input.showPrizes ?? true,
|
|
51
|
+
stages: input.stages ?? [],
|
|
52
|
+
// Only keep currentStageId if it references a stage that actually exists.
|
|
53
|
+
currentStageId: input.currentStageId && (input.stages ?? []).some((s) => s.id === input.currentStageId) ? input.currentStageId : null,
|
|
54
|
+
bannerUrl: input.bannerUrl ?? null,
|
|
55
|
+
coverImageUrl: input.coverImageUrl ?? null,
|
|
56
|
+
prizes: input.prizes ?? null,
|
|
57
|
+
judgingCriteria: input.judgingCriteria ?? null,
|
|
58
|
+
communityVotingEnabled: input.communityVotingEnabled ?? false,
|
|
59
|
+
judgingVisibility: input.judgingVisibility ?? 'judges-only',
|
|
60
|
+
eligibleContentTypes: input.eligibleContentTypes ?? null,
|
|
61
|
+
maxEntriesPerUser: input.maxEntriesPerUser ?? null,
|
|
62
|
+
visibility: input.visibility ?? 'public',
|
|
63
|
+
visibleToRoles: input.visibleToRoles ?? null,
|
|
64
|
+
startDate: new Date(input.startDate),
|
|
65
|
+
endDate: new Date(input.endDate),
|
|
66
|
+
judgingEndDate: input.judgingEndDate ? new Date(input.judgingEndDate) : null,
|
|
67
|
+
createdById: input.createdBy,
|
|
68
|
+
})
|
|
69
|
+
.returning();
|
|
70
|
+
// Single source of truth: seed the contest_judges table from any judge IDs
|
|
71
|
+
// provided at creation. The legacy `judges` jsonb column is no longer written
|
|
72
|
+
// or read — authorization + display use the table exclusively.
|
|
73
|
+
if (input.judges && input.judges.length > 0) {
|
|
74
|
+
await tx
|
|
75
|
+
.insert(contestJudges)
|
|
76
|
+
.values(input.judges.map((userId) => ({ contestId: inserted.id, userId })))
|
|
77
|
+
.onConflictDoNothing();
|
|
78
|
+
}
|
|
79
|
+
// Seed stakeholders (view-only reviewers) from create input.
|
|
80
|
+
if (input.stakeholders && input.stakeholders.length > 0) {
|
|
81
|
+
await tx
|
|
82
|
+
.insert(contestStakeholders)
|
|
83
|
+
.values(input.stakeholders.map((userId) => ({ contestId: inserted.id, userId })))
|
|
84
|
+
.onConflictDoNothing();
|
|
85
|
+
}
|
|
86
|
+
return inserted;
|
|
87
|
+
});
|
|
254
88
|
return toContestDetail(row);
|
|
255
89
|
}
|
|
256
90
|
/**
|
|
@@ -286,6 +120,12 @@ export async function updateContest(db, slug, userId, data, canManage = false) {
|
|
|
286
120
|
updates.rulesFormat = data.rulesFormat;
|
|
287
121
|
if (data.prizesDescriptionFormat !== undefined)
|
|
288
122
|
updates.prizesDescriptionFormat = data.prizesDescriptionFormat;
|
|
123
|
+
if (data.descriptionBlocks !== undefined)
|
|
124
|
+
updates.descriptionBlocks = data.descriptionBlocks;
|
|
125
|
+
if (data.rulesBlocks !== undefined)
|
|
126
|
+
updates.rulesBlocks = data.rulesBlocks;
|
|
127
|
+
if (data.prizesBlocks !== undefined)
|
|
128
|
+
updates.prizesBlocks = data.prizesBlocks;
|
|
289
129
|
if (data.bannerUrl !== undefined)
|
|
290
130
|
updates.bannerUrl = data.bannerUrl;
|
|
291
131
|
if (data.coverImageUrl !== undefined)
|
|
@@ -340,361 +180,6 @@ export async function updateContest(db, slug, userId, data, canManage = false) {
|
|
|
340
180
|
await db.update(contests).set(updates).where(eq(contests.slug, slug));
|
|
341
181
|
return getContestBySlug(db, finalSlug);
|
|
342
182
|
}
|
|
343
|
-
/**
|
|
344
|
-
* Decide whether a viewer may see aggregate entry scores, honouring the
|
|
345
|
-
* contest's `judgingVisibility` setting. Pure + exhaustively testable.
|
|
346
|
-
*
|
|
347
|
-
* - Privileged viewers (owner / admin / panel judge) always see scores.
|
|
348
|
-
* - `public` → scores visible to everyone (during judging and after).
|
|
349
|
-
* - `judges-only`→ scores hidden from the public until the contest completes.
|
|
350
|
-
* - `private` → aggregate scores never exposed to the public (ranks may
|
|
351
|
-
* still be shown so winners can be announced).
|
|
352
|
-
*/
|
|
353
|
-
export function shouldRevealScores(visibility, status, privileged) {
|
|
354
|
-
if (privileged)
|
|
355
|
-
return true;
|
|
356
|
-
if (visibility === 'public')
|
|
357
|
-
return true;
|
|
358
|
-
if (visibility === 'private')
|
|
359
|
-
return false;
|
|
360
|
-
return status === 'completed';
|
|
361
|
-
}
|
|
362
|
-
export async function listContestEntries(db, contestId, opts = {}) {
|
|
363
|
-
const revealScores = opts.revealScores ?? true;
|
|
364
|
-
const { limit, offset } = normalizePagination(opts);
|
|
365
|
-
const where = eq(contestEntries.contestId, contestId);
|
|
366
|
-
// `rank`: ranked entries first (1,2,3…), unranked last; ties broken by score
|
|
367
|
-
// then recency. `recent`: submission order (default).
|
|
368
|
-
const order = opts.orderBy === 'rank'
|
|
369
|
-
? [
|
|
370
|
-
sql `${contestEntries.rank} asc nulls last`,
|
|
371
|
-
sql `${contestEntries.score} desc nulls last`,
|
|
372
|
-
desc(contestEntries.submittedAt),
|
|
373
|
-
]
|
|
374
|
-
: [desc(contestEntries.submittedAt)];
|
|
375
|
-
const [rows, total] = await Promise.all([
|
|
376
|
-
db
|
|
377
|
-
.select({
|
|
378
|
-
entry: contestEntries,
|
|
379
|
-
content: {
|
|
380
|
-
title: contentItems.title,
|
|
381
|
-
slug: contentItems.slug,
|
|
382
|
-
type: contentItems.type,
|
|
383
|
-
coverImageUrl: contentItems.coverImageUrl,
|
|
384
|
-
},
|
|
385
|
-
author: {
|
|
386
|
-
displayName: users.displayName,
|
|
387
|
-
username: users.username,
|
|
388
|
-
avatarUrl: users.avatarUrl,
|
|
389
|
-
},
|
|
390
|
-
})
|
|
391
|
-
.from(contestEntries)
|
|
392
|
-
.innerJoin(contentItems, eq(contestEntries.contentId, contentItems.id))
|
|
393
|
-
.innerJoin(users, eq(contestEntries.userId, users.id))
|
|
394
|
-
.where(where)
|
|
395
|
-
.orderBy(...order)
|
|
396
|
-
.limit(limit)
|
|
397
|
-
.offset(offset),
|
|
398
|
-
countRows(db, contestEntries, where),
|
|
399
|
-
]);
|
|
400
|
-
const items = rows.map((row) => {
|
|
401
|
-
const item = {
|
|
402
|
-
id: row.entry.id,
|
|
403
|
-
contestId: row.entry.contestId,
|
|
404
|
-
contentId: row.entry.contentId,
|
|
405
|
-
userId: row.entry.userId,
|
|
406
|
-
score: revealScores ? row.entry.score : null,
|
|
407
|
-
rank: row.entry.rank,
|
|
408
|
-
// The cohort outcome (advanced/eliminated) is public, but the per-round
|
|
409
|
-
// snapshot SCORE honours revealScores like the live aggregate — otherwise
|
|
410
|
-
// a judges-only/private contest leaks round scores through the snapshots.
|
|
411
|
-
// Rank stays (mirrors the always-exposed top-level rank, so winners can
|
|
412
|
-
// be announced).
|
|
413
|
-
stageState: revealScores
|
|
414
|
-
? row.entry.stageState ?? []
|
|
415
|
-
: (row.entry.stageState ?? []).map((s) => ({ ...s, score: null })),
|
|
416
|
-
eliminated: isEliminated(row.entry),
|
|
417
|
-
submittedAt: row.entry.submittedAt,
|
|
418
|
-
contentTitle: row.content.title,
|
|
419
|
-
contentSlug: row.content.slug,
|
|
420
|
-
contentType: row.content.type,
|
|
421
|
-
contentCoverImageUrl: row.content.coverImageUrl,
|
|
422
|
-
authorName: row.author.displayName ?? row.author.username,
|
|
423
|
-
authorUsername: row.author.username,
|
|
424
|
-
authorAvatarUrl: row.author.avatarUrl,
|
|
425
|
-
};
|
|
426
|
-
if (opts.includeJudgeScores) {
|
|
427
|
-
item.judgeScores = (row.entry.judgeScores ?? []);
|
|
428
|
-
}
|
|
429
|
-
if (opts.includeStageSubmissions || (opts.stageSubmissionsViewerId && row.entry.userId === opts.stageSubmissionsViewerId)) {
|
|
430
|
-
item.stageSubmissions = row.entry.stageSubmissions ?? [];
|
|
431
|
-
}
|
|
432
|
-
return item;
|
|
433
|
-
});
|
|
434
|
-
return { items, total };
|
|
435
|
-
}
|
|
436
|
-
/**
|
|
437
|
-
* One enriched entry by id — content + author info, per-stage artifacts, and
|
|
438
|
-
* per-judge scores. Server-internal: the route layer gates who may see the
|
|
439
|
-
* artifacts/scores (judges/owner/admin + the entrant themselves).
|
|
440
|
-
*/
|
|
441
|
-
export async function getContestEntry(db, entryId) {
|
|
442
|
-
const rows = await db
|
|
443
|
-
.select({
|
|
444
|
-
entry: contestEntries,
|
|
445
|
-
content: {
|
|
446
|
-
title: contentItems.title,
|
|
447
|
-
slug: contentItems.slug,
|
|
448
|
-
type: contentItems.type,
|
|
449
|
-
coverImageUrl: contentItems.coverImageUrl,
|
|
450
|
-
},
|
|
451
|
-
author: {
|
|
452
|
-
displayName: users.displayName,
|
|
453
|
-
username: users.username,
|
|
454
|
-
avatarUrl: users.avatarUrl,
|
|
455
|
-
},
|
|
456
|
-
})
|
|
457
|
-
.from(contestEntries)
|
|
458
|
-
.innerJoin(contentItems, eq(contestEntries.contentId, contentItems.id))
|
|
459
|
-
.innerJoin(users, eq(contestEntries.userId, users.id))
|
|
460
|
-
.where(eq(contestEntries.id, entryId))
|
|
461
|
-
.limit(1);
|
|
462
|
-
const row = rows[0];
|
|
463
|
-
if (!row)
|
|
464
|
-
return null;
|
|
465
|
-
return {
|
|
466
|
-
id: row.entry.id,
|
|
467
|
-
contestId: row.entry.contestId,
|
|
468
|
-
contentId: row.entry.contentId,
|
|
469
|
-
userId: row.entry.userId,
|
|
470
|
-
score: row.entry.score,
|
|
471
|
-
rank: row.entry.rank,
|
|
472
|
-
stageState: row.entry.stageState ?? [],
|
|
473
|
-
eliminated: isEliminated(row.entry),
|
|
474
|
-
stageSubmissions: row.entry.stageSubmissions ?? [],
|
|
475
|
-
submittedAt: row.entry.submittedAt,
|
|
476
|
-
contentTitle: row.content.title,
|
|
477
|
-
contentSlug: row.content.slug,
|
|
478
|
-
contentType: row.content.type,
|
|
479
|
-
contentCoverImageUrl: row.content.coverImageUrl,
|
|
480
|
-
authorName: row.author.displayName ?? row.author.username,
|
|
481
|
-
authorUsername: row.author.username,
|
|
482
|
-
authorAvatarUrl: row.author.avatarUrl,
|
|
483
|
-
judgeScores: (row.entry.judgeScores ?? []),
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
export async function submitContestEntry(db, contestId, contentId, userId) {
|
|
487
|
-
// Validate contest exists and is active
|
|
488
|
-
const contest = await db
|
|
489
|
-
.select({
|
|
490
|
-
id: contests.id,
|
|
491
|
-
status: contests.status,
|
|
492
|
-
eligibleContentTypes: contests.eligibleContentTypes,
|
|
493
|
-
maxEntriesPerUser: contests.maxEntriesPerUser,
|
|
494
|
-
})
|
|
495
|
-
.from(contests)
|
|
496
|
-
.where(eq(contests.id, contestId))
|
|
497
|
-
.limit(1);
|
|
498
|
-
if (contest.length === 0)
|
|
499
|
-
return null;
|
|
500
|
-
const c = contest[0];
|
|
501
|
-
if (c.status !== 'active')
|
|
502
|
-
return null;
|
|
503
|
-
// Validate content exists, is published, and user owns it
|
|
504
|
-
const content = await db
|
|
505
|
-
.select({ id: contentItems.id, authorId: contentItems.authorId, status: contentItems.status, type: contentItems.type })
|
|
506
|
-
.from(contentItems)
|
|
507
|
-
.where(eq(contentItems.id, contentId))
|
|
508
|
-
.limit(1);
|
|
509
|
-
if (content.length === 0)
|
|
510
|
-
return null;
|
|
511
|
-
if (content[0].status !== 'published')
|
|
512
|
-
return null;
|
|
513
|
-
if (content[0].authorId !== userId)
|
|
514
|
-
return null;
|
|
515
|
-
// Per-contest entry eligibility: content type must be allowed (if restricted).
|
|
516
|
-
const eligible = c.eligibleContentTypes ?? null;
|
|
517
|
-
if (eligible && eligible.length > 0 && !eligible.includes(content[0].type))
|
|
518
|
-
return null;
|
|
519
|
-
// Per-user entry cap (if set).
|
|
520
|
-
if (c.maxEntriesPerUser != null) {
|
|
521
|
-
const existingCount = await countRows(db, contestEntries, and(eq(contestEntries.contestId, contestId), eq(contestEntries.userId, userId)));
|
|
522
|
-
if (existingCount >= c.maxEntriesPerUser)
|
|
523
|
-
return null;
|
|
524
|
-
}
|
|
525
|
-
// Atomic: insert the entry and bump the denormalized entryCount together, so a
|
|
526
|
-
// duplicate (onConflictDoNothing → no row) never increments and a mid-operation
|
|
527
|
-
// failure can't leave entryCount overcounting.
|
|
528
|
-
const row = await db.transaction(async (tx) => {
|
|
529
|
-
const [inserted] = await tx
|
|
530
|
-
.insert(contestEntries)
|
|
531
|
-
.values({ contestId, contentId, userId })
|
|
532
|
-
.onConflictDoNothing()
|
|
533
|
-
.returning();
|
|
534
|
-
if (!inserted)
|
|
535
|
-
return null;
|
|
536
|
-
await tx
|
|
537
|
-
.update(contests)
|
|
538
|
-
.set({ entryCount: sql `${contests.entryCount} + 1` })
|
|
539
|
-
.where(eq(contests.id, contestId));
|
|
540
|
-
return inserted;
|
|
541
|
-
});
|
|
542
|
-
if (!row)
|
|
543
|
-
return null;
|
|
544
|
-
// Fetch enriched content + author info
|
|
545
|
-
const enriched = await db
|
|
546
|
-
.select({
|
|
547
|
-
content: {
|
|
548
|
-
title: contentItems.title,
|
|
549
|
-
slug: contentItems.slug,
|
|
550
|
-
type: contentItems.type,
|
|
551
|
-
coverImageUrl: contentItems.coverImageUrl,
|
|
552
|
-
},
|
|
553
|
-
author: {
|
|
554
|
-
displayName: users.displayName,
|
|
555
|
-
username: users.username,
|
|
556
|
-
avatarUrl: users.avatarUrl,
|
|
557
|
-
},
|
|
558
|
-
})
|
|
559
|
-
.from(contentItems)
|
|
560
|
-
.innerJoin(users, eq(contentItems.authorId, users.id))
|
|
561
|
-
.where(eq(contentItems.id, contentId))
|
|
562
|
-
.limit(1);
|
|
563
|
-
const info = enriched[0];
|
|
564
|
-
return {
|
|
565
|
-
id: row.id,
|
|
566
|
-
contestId: row.contestId,
|
|
567
|
-
contentId: row.contentId,
|
|
568
|
-
userId: row.userId,
|
|
569
|
-
score: row.score,
|
|
570
|
-
rank: row.rank,
|
|
571
|
-
stageState: [], // a freshly submitted entry is in the active cohort
|
|
572
|
-
eliminated: false,
|
|
573
|
-
submittedAt: row.submittedAt,
|
|
574
|
-
contentTitle: info?.content.title ?? 'Untitled',
|
|
575
|
-
contentSlug: info?.content.slug ?? '',
|
|
576
|
-
contentType: info?.content.type ?? 'project',
|
|
577
|
-
contentCoverImageUrl: info?.content.coverImageUrl ?? null,
|
|
578
|
-
authorName: info?.author.displayName ?? info?.author.username ?? 'Unknown',
|
|
579
|
-
authorUsername: info?.author.username ?? '',
|
|
580
|
-
authorAvatarUrl: info?.author.avatarUrl ?? null,
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
export async function judgeContestEntry(db, entryId, score, judgeId, feedback, criteriaScores) {
|
|
584
|
-
// Get the entry and its contest (read-only validation, no lock needed).
|
|
585
|
-
const existing = await db
|
|
586
|
-
.select({
|
|
587
|
-
contestStatus: contests.status,
|
|
588
|
-
contestId: contests.id,
|
|
589
|
-
entrantId: contestEntries.userId,
|
|
590
|
-
stageState: contestEntries.stageState,
|
|
591
|
-
stages: contests.stages,
|
|
592
|
-
currentStageId: contests.currentStageId,
|
|
593
|
-
startDate: contests.startDate,
|
|
594
|
-
endDate: contests.endDate,
|
|
595
|
-
judgingEndDate: contests.judgingEndDate,
|
|
596
|
-
})
|
|
597
|
-
.from(contestEntries)
|
|
598
|
-
.innerJoin(contests, eq(contestEntries.contestId, contests.id))
|
|
599
|
-
.where(eq(contestEntries.id, entryId))
|
|
600
|
-
.limit(1);
|
|
601
|
-
if (existing.length === 0)
|
|
602
|
-
return { judged: false, error: 'Entry not found' };
|
|
603
|
-
const row = existing[0];
|
|
604
|
-
// Check contest is in judging phase
|
|
605
|
-
if (row.contestStatus !== 'judging') {
|
|
606
|
-
return { judged: false, error: 'Contest is not in judging phase' };
|
|
607
|
-
}
|
|
608
|
-
// Cohort gate (Phase B2.5): once a review stage has culled the field, entries
|
|
609
|
-
// that didn't advance are out of later rounds and can't be scored.
|
|
610
|
-
if (isEliminated({ stageState: row.stageState })) {
|
|
611
|
-
return { judged: false, error: 'This entry was not advanced and can no longer be scored' };
|
|
612
|
-
}
|
|
613
|
-
// Per-round isolation: which review round is this score for? The entry's live
|
|
614
|
-
// `score` will aggregate only THIS round's judge scores (a classic contest with
|
|
615
|
-
// no explicit stages resolves to the synthesized `core-review`, so it stays one
|
|
616
|
-
// bucket — unchanged single-round behaviour).
|
|
617
|
-
const roundStage = currentStage({
|
|
618
|
-
status: row.contestStatus,
|
|
619
|
-
startDate: row.startDate,
|
|
620
|
-
endDate: row.endDate,
|
|
621
|
-
judgingEndDate: row.judgingEndDate,
|
|
622
|
-
stages: row.stages,
|
|
623
|
-
currentStageId: row.currentStageId,
|
|
624
|
-
});
|
|
625
|
-
const roundId = roundStage && roundStage.kind === 'review' ? roundStage.id : null;
|
|
626
|
-
// Conflict of interest: a judge cannot score their own entry.
|
|
627
|
-
if (row.entrantId === judgeId) {
|
|
628
|
-
return { judged: false, error: 'You cannot judge your own entry' };
|
|
629
|
-
}
|
|
630
|
-
// Check judge authorization via contestJudges table (accepted judges only)
|
|
631
|
-
const [judgeRecord] = await db
|
|
632
|
-
.select({ id: contestJudges.id, role: contestJudges.role, acceptedAt: contestJudges.acceptedAt })
|
|
633
|
-
.from(contestJudges)
|
|
634
|
-
.where(and(eq(contestJudges.contestId, row.contestId), eq(contestJudges.userId, judgeId)))
|
|
635
|
-
.limit(1);
|
|
636
|
-
if (!judgeRecord) {
|
|
637
|
-
return { judged: false, error: 'Not authorized to judge this contest' };
|
|
638
|
-
}
|
|
639
|
-
if (!judgeRecord.acceptedAt) {
|
|
640
|
-
return { judged: false, error: 'Judge invitation has not been accepted' };
|
|
641
|
-
}
|
|
642
|
-
if (judgeRecord.role === 'guest') {
|
|
643
|
-
return { judged: false, error: 'Guest judges cannot submit scores' };
|
|
644
|
-
}
|
|
645
|
-
// Derive the overall 0–100 score. When per-criterion scores are supplied, the
|
|
646
|
-
// overall is the normalized weighted sum (sum(score)/sum(max)*100), which
|
|
647
|
-
// supports any weight scheme; otherwise use the supplied overall score.
|
|
648
|
-
let overall;
|
|
649
|
-
if (criteriaScores && criteriaScores.length > 0) {
|
|
650
|
-
const totalMax = criteriaScores.reduce((s, c) => s + c.max, 0);
|
|
651
|
-
if (totalMax <= 0)
|
|
652
|
-
return { judged: false, error: 'Invalid judging criteria' };
|
|
653
|
-
if (criteriaScores.some((c) => c.score < 0 || c.score > c.max)) {
|
|
654
|
-
return { judged: false, error: 'A criterion score is out of range' };
|
|
655
|
-
}
|
|
656
|
-
overall = Math.round((criteriaScores.reduce((s, c) => s + c.score, 0) / totalMax) * 100);
|
|
657
|
-
}
|
|
658
|
-
else if (typeof score === 'number') {
|
|
659
|
-
overall = score;
|
|
660
|
-
}
|
|
661
|
-
else {
|
|
662
|
-
return { judged: false, error: 'No score provided' };
|
|
663
|
-
}
|
|
664
|
-
// Atomic read-modify-write: lock the entry row so two judges scoring the same
|
|
665
|
-
// entry concurrently can't clobber each other's judgeScores (lost update).
|
|
666
|
-
return db.transaction(async (tx) => {
|
|
667
|
-
const [locked] = await tx
|
|
668
|
-
.select({ judgeScores: contestEntries.judgeScores })
|
|
669
|
-
.from(contestEntries)
|
|
670
|
-
.where(eq(contestEntries.id, entryId))
|
|
671
|
-
.for('update');
|
|
672
|
-
const scores = (locked?.judgeScores ?? []);
|
|
673
|
-
const record = { judgeId, score: overall, feedback };
|
|
674
|
-
if (criteriaScores && criteriaScores.length > 0)
|
|
675
|
-
record.criteriaScores = criteriaScores;
|
|
676
|
-
if (roundId)
|
|
677
|
-
record.roundId = roundId;
|
|
678
|
-
// A judge has one score per round — match on judge AND round.
|
|
679
|
-
const existingIdx = scores.findIndex((s) => s.judgeId === judgeId && (s.roundId ?? null) === (roundId ?? null));
|
|
680
|
-
if (existingIdx >= 0)
|
|
681
|
-
scores[existingIdx] = record;
|
|
682
|
-
else
|
|
683
|
-
scores.push(record);
|
|
684
|
-
// The live aggregate reflects ONLY the current round's scores, so a later
|
|
685
|
-
// judging round doesn't blend with an earlier one. Earlier rounds stay in
|
|
686
|
-
// `judgeScores` (tagged with their roundId) as history.
|
|
687
|
-
const roundScores = roundId ? scores.filter((s) => (s.roundId ?? null) === roundId) : scores;
|
|
688
|
-
const avgScore = roundScores.length
|
|
689
|
-
? Math.round(roundScores.reduce((sum, s) => sum + s.score, 0) / roundScores.length)
|
|
690
|
-
: 0;
|
|
691
|
-
await tx
|
|
692
|
-
.update(contestEntries)
|
|
693
|
-
.set({ judgeScores: scores, score: avgScore })
|
|
694
|
-
.where(eq(contestEntries.id, entryId));
|
|
695
|
-
return { judged: true };
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
183
|
// --- Contest Management ---
|
|
699
184
|
export async function deleteContest(db, contestId, userId,
|
|
700
185
|
/**
|
|
@@ -749,16 +234,20 @@ export async function transitionContestStatus(db, contestId, userId, newStatus,
|
|
|
749
234
|
if (!allowed.includes(newStatus)) {
|
|
750
235
|
return { transitioned: false, error: `Cannot transition from ${currentStatus} to ${newStatus}` };
|
|
751
236
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
237
|
+
// Status flip + (on completion) rank calculation must be atomic so we never
|
|
238
|
+
// leave a 'completed' contest with stale/partial ranks.
|
|
239
|
+
await db.transaction(async (tx) => {
|
|
240
|
+
await tx
|
|
241
|
+
.update(contests)
|
|
242
|
+
.set({
|
|
243
|
+
status: newStatus,
|
|
244
|
+
updatedAt: new Date(),
|
|
245
|
+
})
|
|
246
|
+
.where(eq(contests.id, contestId));
|
|
247
|
+
if (newStatus === 'completed') {
|
|
248
|
+
await calculateContestRanks(tx, contestId);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
762
251
|
// Notify contest entrants about status change (non-critical)
|
|
763
252
|
try {
|
|
764
253
|
const { createNotification } = await import('../notification/notification.js');
|
|
@@ -796,7 +285,7 @@ export async function transitionContestStatus(db, contestId, userId, newStatus,
|
|
|
796
285
|
createNotification(db, {
|
|
797
286
|
userId: entrant.userId,
|
|
798
287
|
type: 'contest',
|
|
799
|
-
title: '
|
|
288
|
+
title: 'You won!',
|
|
800
289
|
message: `Congratulations — you placed ${ordinalPlace(rank)} in "${contestInfo.title}"${won}!`,
|
|
801
290
|
link,
|
|
802
291
|
actorId: userId,
|
|
@@ -843,284 +332,4 @@ export async function transitionContestStatus(db, contestId, userId, newStatus,
|
|
|
843
332
|
catch { /* non-critical */ }
|
|
844
333
|
return { transitioned: true };
|
|
845
334
|
}
|
|
846
|
-
export async function withdrawContestEntry(db, entryId, userId) {
|
|
847
|
-
const existing = await db
|
|
848
|
-
.select({
|
|
849
|
-
entry: contestEntries,
|
|
850
|
-
contestStatus: contests.status,
|
|
851
|
-
})
|
|
852
|
-
.from(contestEntries)
|
|
853
|
-
.innerJoin(contests, eq(contestEntries.contestId, contests.id))
|
|
854
|
-
.where(eq(contestEntries.id, entryId))
|
|
855
|
-
.limit(1);
|
|
856
|
-
if (existing.length === 0)
|
|
857
|
-
return { withdrawn: false, error: 'Entry not found' };
|
|
858
|
-
const row = existing[0];
|
|
859
|
-
if (row.entry.userId !== userId)
|
|
860
|
-
return { withdrawn: false, error: 'Not the entry owner' };
|
|
861
|
-
if (row.contestStatus !== 'active') {
|
|
862
|
-
return { withdrawn: false, error: 'Can only withdraw from active contests' };
|
|
863
|
-
}
|
|
864
|
-
await db.delete(contestEntries).where(eq(contestEntries.id, entryId));
|
|
865
|
-
await db
|
|
866
|
-
.update(contests)
|
|
867
|
-
.set({ entryCount: sql `GREATEST(${contests.entryCount} - 1, 0)` })
|
|
868
|
-
.where(eq(contests.id, row.entry.contestId));
|
|
869
|
-
return { withdrawn: true };
|
|
870
|
-
}
|
|
871
|
-
export async function calculateContestRanks(db, contestId) {
|
|
872
|
-
// Assign ranks by score with RANK() so tied scores share a rank (1, 1, 3…).
|
|
873
|
-
// Only scored entries are ranked; entries that were never judged keep a null
|
|
874
|
-
// rank rather than being handed an arbitrary trailing position.
|
|
875
|
-
// Eliminated entries (culled at a prior review stage, Phase B2) are excluded
|
|
876
|
-
// from ranking — only the surviving cohort competes for the final placements.
|
|
877
|
-
await db.execute(sql `
|
|
878
|
-
UPDATE ${contestEntries}
|
|
879
|
-
SET rank = ranked.rn
|
|
880
|
-
FROM (
|
|
881
|
-
SELECT id, RANK() OVER (ORDER BY score DESC) AS rn
|
|
882
|
-
FROM ${contestEntries}
|
|
883
|
-
WHERE contest_id = ${contestId} AND score IS NOT NULL
|
|
884
|
-
AND NOT (stage_state @> '[{"status":"eliminated"}]'::jsonb)
|
|
885
|
-
) AS ranked
|
|
886
|
-
WHERE ${contestEntries}.id = ranked.id
|
|
887
|
-
`);
|
|
888
|
-
// Clear ranks for entries with no score (unjudged) or that were eliminated.
|
|
889
|
-
await db.execute(sql `
|
|
890
|
-
UPDATE ${contestEntries}
|
|
891
|
-
SET rank = NULL
|
|
892
|
-
WHERE contest_id = ${contestId}
|
|
893
|
-
AND (score IS NULL OR stage_state @> '[{"status":"eliminated"}]'::jsonb)
|
|
894
|
-
`);
|
|
895
|
-
}
|
|
896
|
-
/** True when an entry was culled at some review stage (Phase B2 cohort gate). */
|
|
897
|
-
export function isEliminated(entry) {
|
|
898
|
-
return !!entry.stageState?.some((s) => s.status === 'eliminated');
|
|
899
|
-
}
|
|
900
|
-
// --- Per-stage submission artifacts (proposal → prototype) ---
|
|
901
|
-
/**
|
|
902
|
-
* Validate an entrant's artifact fields against the stage's template. Pure +
|
|
903
|
-
* exhaustively testable. Domain checks, not just shape: unknown keys rejected
|
|
904
|
-
* (no smuggling values outside the template), required fields must be
|
|
905
|
-
* non-blank, and `url` fields must be real http(s) URLs — `javascript:` and
|
|
906
|
-
* friends are known-bad payloads, not "strings that look url-ish".
|
|
907
|
-
*/
|
|
908
|
-
export function validateStageArtifactFields(template, fields) {
|
|
909
|
-
const byKey = new Map(template.map((f) => [f.key, f]));
|
|
910
|
-
for (const key of Object.keys(fields)) {
|
|
911
|
-
if (!byKey.has(key))
|
|
912
|
-
return { ok: false, error: `Unknown field: ${key}` };
|
|
913
|
-
if (typeof fields[key] !== 'string')
|
|
914
|
-
return { ok: false, error: `Invalid value for ${key}` };
|
|
915
|
-
if (fields[key].length > 4000)
|
|
916
|
-
return { ok: false, error: `${byKey.get(key).label} is too long (max 4000 characters)` };
|
|
917
|
-
}
|
|
918
|
-
const clean = {};
|
|
919
|
-
for (const field of template) {
|
|
920
|
-
const raw = fields[field.key] ?? '';
|
|
921
|
-
const value = raw.trim();
|
|
922
|
-
if (!value) {
|
|
923
|
-
if (field.required)
|
|
924
|
-
return { ok: false, error: `${field.label} is required` };
|
|
925
|
-
continue; // optional + blank ⇒ omit from the snapshot
|
|
926
|
-
}
|
|
927
|
-
if (field.type === 'url') {
|
|
928
|
-
// Scheme allow-list FIRST (https?:// only), then structural URL parse.
|
|
929
|
-
if (!/^https?:\/\//i.test(value))
|
|
930
|
-
return { ok: false, error: `${field.label} must be an http(s) URL` };
|
|
931
|
-
try {
|
|
932
|
-
const u = new URL(value);
|
|
933
|
-
if (!u.hostname)
|
|
934
|
-
return { ok: false, error: `${field.label} must be a valid URL` };
|
|
935
|
-
}
|
|
936
|
-
catch {
|
|
937
|
-
return { ok: false, error: `${field.label} must be a valid URL` };
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
clean[field.key] = value;
|
|
941
|
-
}
|
|
942
|
-
return { ok: true, fields: clean };
|
|
943
|
-
}
|
|
944
|
-
/**
|
|
945
|
-
* Submit (or update) an entrant's per-stage artifact: the filled template
|
|
946
|
-
* values for one `submission` stage, snapshotted onto the entry's
|
|
947
|
-
* `stageSubmissions`. Owner-only. The stage must be the contest's CURRENT
|
|
948
|
-
* stage while the contest is `active` (status stays the gating truth — the
|
|
949
|
-
* organizer maps a later submission round back to `active` when advancing).
|
|
950
|
-
* Re-submitting while the stage is open replaces that stage's artifact.
|
|
951
|
-
* Cohort gate: an entry culled at a prior review stage can no longer submit.
|
|
952
|
-
*/
|
|
953
|
-
export async function submitStageArtifact(db, entryId, stageId, fields, userId) {
|
|
954
|
-
const fail = (error) => ({ submitted: false, error });
|
|
955
|
-
const existing = await db
|
|
956
|
-
.select({
|
|
957
|
-
entrantId: contestEntries.userId,
|
|
958
|
-
stageState: contestEntries.stageState,
|
|
959
|
-
contestStatus: contests.status,
|
|
960
|
-
stages: contests.stages,
|
|
961
|
-
currentStageId: contests.currentStageId,
|
|
962
|
-
startDate: contests.startDate,
|
|
963
|
-
endDate: contests.endDate,
|
|
964
|
-
judgingEndDate: contests.judgingEndDate,
|
|
965
|
-
})
|
|
966
|
-
.from(contestEntries)
|
|
967
|
-
.innerJoin(contests, eq(contestEntries.contestId, contests.id))
|
|
968
|
-
.where(eq(contestEntries.id, entryId))
|
|
969
|
-
.limit(1);
|
|
970
|
-
if (existing.length === 0)
|
|
971
|
-
return fail('Entry not found');
|
|
972
|
-
const row = existing[0];
|
|
973
|
-
if (row.entrantId !== userId)
|
|
974
|
-
return fail('Not the entry owner');
|
|
975
|
-
if (row.contestStatus !== 'active') {
|
|
976
|
-
return fail('Stage submissions are only open while the contest is active');
|
|
977
|
-
}
|
|
978
|
-
const source = {
|
|
979
|
-
status: row.contestStatus,
|
|
980
|
-
startDate: row.startDate,
|
|
981
|
-
endDate: row.endDate,
|
|
982
|
-
judgingEndDate: row.judgingEndDate,
|
|
983
|
-
stages: row.stages,
|
|
984
|
-
currentStageId: row.currentStageId,
|
|
985
|
-
};
|
|
986
|
-
const stages = normalizeStages(source);
|
|
987
|
-
const stage = stages.find((s) => s.id === stageId);
|
|
988
|
-
if (!stage)
|
|
989
|
-
return fail('Unknown stage');
|
|
990
|
-
if (stage.kind !== 'submission')
|
|
991
|
-
return fail('This stage does not accept submissions');
|
|
992
|
-
const template = stage.submissionTemplate ?? [];
|
|
993
|
-
if (template.length === 0)
|
|
994
|
-
return fail('This stage has no submission template');
|
|
995
|
-
const current = currentStage(source);
|
|
996
|
-
if (current?.id !== stageId)
|
|
997
|
-
return fail('This stage is not currently open');
|
|
998
|
-
// Cohort gate: once a review cut culled the field, eliminated entries are
|
|
999
|
-
// out of every later round (mirrors judgeContestEntry's gate).
|
|
1000
|
-
if (isEliminated({ stageState: row.stageState })) {
|
|
1001
|
-
return fail('This entry was not advanced and can no longer submit');
|
|
1002
|
-
}
|
|
1003
|
-
const validated = validateStageArtifactFields(template, fields);
|
|
1004
|
-
if (!validated.ok)
|
|
1005
|
-
return fail(validated.error);
|
|
1006
|
-
// Atomic read-modify-write: lock the entry row so two concurrent saves of
|
|
1007
|
-
// the same artifact can't clobber each other (same pattern as judgeScores).
|
|
1008
|
-
return db.transaction(async (tx) => {
|
|
1009
|
-
const [locked] = await tx
|
|
1010
|
-
.select({ stageSubmissions: contestEntries.stageSubmissions })
|
|
1011
|
-
.from(contestEntries)
|
|
1012
|
-
.where(eq(contestEntries.id, entryId))
|
|
1013
|
-
.for('update');
|
|
1014
|
-
const submissions = (locked?.stageSubmissions ?? []);
|
|
1015
|
-
const record = { stageId, fields: validated.fields, submittedAt: new Date().toISOString() };
|
|
1016
|
-
const idx = submissions.findIndex((s) => s.stageId === stageId);
|
|
1017
|
-
if (idx >= 0)
|
|
1018
|
-
submissions[idx] = record;
|
|
1019
|
-
else
|
|
1020
|
-
submissions.push(record);
|
|
1021
|
-
await tx
|
|
1022
|
-
.update(contestEntries)
|
|
1023
|
-
.set({ stageSubmissions: submissions })
|
|
1024
|
-
.where(eq(contestEntries.id, entryId));
|
|
1025
|
-
return { submitted: true, stageSubmissions: submissions };
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
/**
|
|
1029
|
-
* Phase B2 — apply an advancement cut at a review stage: the surviving cohort
|
|
1030
|
-
* (entries not already eliminated) is split into advancers + eliminated, the
|
|
1031
|
-
* round's score/rank is snapshotted into each entry's `stageState`, and the
|
|
1032
|
-
* contest's `currentStageId` moves to the next stage. Idempotent per stage —
|
|
1033
|
-
* re-running replaces that stage's `stageState` rows rather than duplicating them.
|
|
1034
|
-
* Owner-gated. `topN` ties broken by score → rank → id for determinism.
|
|
1035
|
-
*/
|
|
1036
|
-
export async function advanceContestStage(db, contestId, userId, input, canManage = false) {
|
|
1037
|
-
const fail = (error) => ({ advanced: false, advancedCount: 0, eliminatedCount: 0, error });
|
|
1038
|
-
const [contest] = await db
|
|
1039
|
-
.select({
|
|
1040
|
-
createdById: contests.createdById,
|
|
1041
|
-
status: contests.status,
|
|
1042
|
-
stages: contests.stages,
|
|
1043
|
-
currentStageId: contests.currentStageId,
|
|
1044
|
-
startDate: contests.startDate,
|
|
1045
|
-
endDate: contests.endDate,
|
|
1046
|
-
judgingEndDate: contests.judgingEndDate,
|
|
1047
|
-
})
|
|
1048
|
-
.from(contests)
|
|
1049
|
-
.where(eq(contests.id, contestId))
|
|
1050
|
-
.limit(1);
|
|
1051
|
-
if (!contest)
|
|
1052
|
-
return fail('Contest not found');
|
|
1053
|
-
if (contest.createdById !== userId && !canManage && !(await isContestEditor(db, contestId, userId))) {
|
|
1054
|
-
return fail('Not authorized to manage this contest');
|
|
1055
|
-
}
|
|
1056
|
-
const stages = normalizeStages(contest);
|
|
1057
|
-
const idx = stages.findIndex((s) => s.id === input.reviewStageId);
|
|
1058
|
-
if (idx < 0)
|
|
1059
|
-
return fail('Unknown stage');
|
|
1060
|
-
if (stages[idx].kind !== 'review')
|
|
1061
|
-
return fail('Advancement applies to review stages only');
|
|
1062
|
-
const rows = await db
|
|
1063
|
-
.select({ id: contestEntries.id, userId: contestEntries.userId, score: contestEntries.score, rank: contestEntries.rank, stageState: contestEntries.stageState })
|
|
1064
|
-
.from(contestEntries)
|
|
1065
|
-
.where(eq(contestEntries.contestId, contestId));
|
|
1066
|
-
// Only the running cohort (not already eliminated) is subject to the cut.
|
|
1067
|
-
const eligible = rows.filter((r) => !isEliminated(r));
|
|
1068
|
-
let advancedIds;
|
|
1069
|
-
if (input.mode === 'manual') {
|
|
1070
|
-
const picked = new Set(input.advancedEntryIds ?? []);
|
|
1071
|
-
advancedIds = new Set(eligible.filter((e) => picked.has(e.id)).map((e) => e.id));
|
|
1072
|
-
}
|
|
1073
|
-
else {
|
|
1074
|
-
const n = Math.max(0, input.topN ?? 0);
|
|
1075
|
-
const sorted = [...eligible].sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity) ||
|
|
1076
|
-
(a.rank ?? Infinity) - (b.rank ?? Infinity) ||
|
|
1077
|
-
a.id.localeCompare(b.id));
|
|
1078
|
-
advancedIds = new Set(sorted.slice(0, n).map((e) => e.id));
|
|
1079
|
-
}
|
|
1080
|
-
let advancedCount = 0;
|
|
1081
|
-
let eliminatedCount = 0;
|
|
1082
|
-
for (const e of eligible) {
|
|
1083
|
-
const isAdv = advancedIds.has(e.id);
|
|
1084
|
-
const prior = (e.stageState ?? []).filter((s) => s.stageId !== input.reviewStageId);
|
|
1085
|
-
const next = [...prior, { stageId: input.reviewStageId, status: isAdv ? 'advanced' : 'eliminated', score: e.score ?? null, rank: e.rank ?? null }];
|
|
1086
|
-
await db.update(contestEntries).set({ stageState: next }).where(eq(contestEntries.id, e.id));
|
|
1087
|
-
if (isAdv)
|
|
1088
|
-
advancedCount++;
|
|
1089
|
-
else
|
|
1090
|
-
eliminatedCount++;
|
|
1091
|
-
}
|
|
1092
|
-
const nextStage = stages[idx + 1];
|
|
1093
|
-
if (nextStage) {
|
|
1094
|
-
await db.update(contests).set({ currentStageId: nextStage.id, updatedAt: new Date() }).where(eq(contests.id, contestId));
|
|
1095
|
-
}
|
|
1096
|
-
// Notify entrants of the outcome (non-critical, de-duped by user).
|
|
1097
|
-
try {
|
|
1098
|
-
const { createNotification } = await import('../notification/notification.js');
|
|
1099
|
-
const [info] = await db.select({ title: contests.title, slug: contests.slug }).from(contests).where(eq(contests.id, contestId)).limit(1);
|
|
1100
|
-
if (info) {
|
|
1101
|
-
const nextName = nextStage?.name ?? 'the next stage';
|
|
1102
|
-
const seen = new Set();
|
|
1103
|
-
for (const e of eligible) {
|
|
1104
|
-
if (seen.has(e.userId))
|
|
1105
|
-
continue;
|
|
1106
|
-
seen.add(e.userId);
|
|
1107
|
-
const adv = advancedIds.has(e.id);
|
|
1108
|
-
createNotification(db, {
|
|
1109
|
-
userId: e.userId,
|
|
1110
|
-
type: 'contest',
|
|
1111
|
-
title: adv ? '✅ You advanced!' : 'Contest update',
|
|
1112
|
-
message: adv
|
|
1113
|
-
? `Your entry advanced to ${nextName} in "${info.title}".`
|
|
1114
|
-
: `Your entry wasn't selected to continue in "${info.title}".`,
|
|
1115
|
-
link: `/contests/${info.slug}`,
|
|
1116
|
-
actorId: userId,
|
|
1117
|
-
}).catch(() => { });
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
catch {
|
|
1122
|
-
/* non-critical */
|
|
1123
|
-
}
|
|
1124
|
-
return { advanced: true, advancedCount, eliminatedCount };
|
|
1125
|
-
}
|
|
1126
335
|
//# sourceMappingURL=contest.js.map
|