@commonpub/server 2.88.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 +9 -0
- package/dist/admin/admin.d.ts.map +1 -1
- package/dist/admin/admin.js +50 -13
- 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 +7 -300
- package/dist/contest/contest.d.ts.map +1 -1
- package/dist/contest/contest.js +99 -882
- 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 +12 -3
- package/dist/contest/index.d.ts.map +1 -1
- package/dist/contest/index.js +9 -2
- 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/stakeholders.d.ts +32 -7
- package/dist/contest/stakeholders.d.ts.map +1 -1
- package/dist/contest/stakeholders.js +55 -14
- package/dist/contest/stakeholders.js.map +1 -1
- 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 +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- 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/admin.d.ts +43 -0
- package/dist/rbac/admin.d.ts.map +1 -0
- package/dist/rbac/admin.js +172 -0
- package/dist/rbac/admin.js.map +1 -0
- package/dist/rbac/index.d.ts +4 -0
- package/dist/rbac/index.d.ts.map +1 -1
- package/dist/rbac/index.js +2 -0
- package/dist/rbac/index.js.map +1 -1
- package/dist/rbac/seed.d.ts +30 -0
- package/dist/rbac/seed.d.ts.map +1 -0
- package/dist/rbac/seed.js +75 -0
- package/dist/rbac/seed.js.map +1 -0
- 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 +9 -7
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,57 +28,71 @@ 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
|
+
/**
|
|
91
|
+
* Update a contest. Authorized for the owner, a per-contest `editor` stakeholder,
|
|
92
|
+
* or a caller the route already cleared via `contest.manage` (`canManage=true`).
|
|
93
|
+
* Returns null when the contest is missing OR the caller is not authorized.
|
|
94
|
+
*/
|
|
95
|
+
export async function updateContest(db, slug, userId, data, canManage = false) {
|
|
257
96
|
const existing = await db
|
|
258
97
|
.select()
|
|
259
98
|
.from(contests)
|
|
@@ -261,7 +100,8 @@ export async function updateContest(db, slug, userId, data) {
|
|
|
261
100
|
.limit(1);
|
|
262
101
|
if (existing.length === 0)
|
|
263
102
|
return null;
|
|
264
|
-
|
|
103
|
+
const isOwner = existing[0].createdById === userId;
|
|
104
|
+
if (!isOwner && !canManage && !(await isContestEditor(db, existing[0].id, userId)))
|
|
265
105
|
return null;
|
|
266
106
|
const updates = { updatedAt: new Date() };
|
|
267
107
|
if (data.title !== undefined)
|
|
@@ -280,6 +120,12 @@ export async function updateContest(db, slug, userId, data) {
|
|
|
280
120
|
updates.rulesFormat = data.rulesFormat;
|
|
281
121
|
if (data.prizesDescriptionFormat !== undefined)
|
|
282
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;
|
|
283
129
|
if (data.bannerUrl !== undefined)
|
|
284
130
|
updates.bannerUrl = data.bannerUrl;
|
|
285
131
|
if (data.coverImageUrl !== undefined)
|
|
@@ -334,361 +180,6 @@ export async function updateContest(db, slug, userId, data) {
|
|
|
334
180
|
await db.update(contests).set(updates).where(eq(contests.slug, slug));
|
|
335
181
|
return getContestBySlug(db, finalSlug);
|
|
336
182
|
}
|
|
337
|
-
/**
|
|
338
|
-
* Decide whether a viewer may see aggregate entry scores, honouring the
|
|
339
|
-
* contest's `judgingVisibility` setting. Pure + exhaustively testable.
|
|
340
|
-
*
|
|
341
|
-
* - Privileged viewers (owner / admin / panel judge) always see scores.
|
|
342
|
-
* - `public` → scores visible to everyone (during judging and after).
|
|
343
|
-
* - `judges-only`→ scores hidden from the public until the contest completes.
|
|
344
|
-
* - `private` → aggregate scores never exposed to the public (ranks may
|
|
345
|
-
* still be shown so winners can be announced).
|
|
346
|
-
*/
|
|
347
|
-
export function shouldRevealScores(visibility, status, privileged) {
|
|
348
|
-
if (privileged)
|
|
349
|
-
return true;
|
|
350
|
-
if (visibility === 'public')
|
|
351
|
-
return true;
|
|
352
|
-
if (visibility === 'private')
|
|
353
|
-
return false;
|
|
354
|
-
return status === 'completed';
|
|
355
|
-
}
|
|
356
|
-
export async function listContestEntries(db, contestId, opts = {}) {
|
|
357
|
-
const revealScores = opts.revealScores ?? true;
|
|
358
|
-
const { limit, offset } = normalizePagination(opts);
|
|
359
|
-
const where = eq(contestEntries.contestId, contestId);
|
|
360
|
-
// `rank`: ranked entries first (1,2,3…), unranked last; ties broken by score
|
|
361
|
-
// then recency. `recent`: submission order (default).
|
|
362
|
-
const order = opts.orderBy === 'rank'
|
|
363
|
-
? [
|
|
364
|
-
sql `${contestEntries.rank} asc nulls last`,
|
|
365
|
-
sql `${contestEntries.score} desc nulls last`,
|
|
366
|
-
desc(contestEntries.submittedAt),
|
|
367
|
-
]
|
|
368
|
-
: [desc(contestEntries.submittedAt)];
|
|
369
|
-
const [rows, total] = await Promise.all([
|
|
370
|
-
db
|
|
371
|
-
.select({
|
|
372
|
-
entry: contestEntries,
|
|
373
|
-
content: {
|
|
374
|
-
title: contentItems.title,
|
|
375
|
-
slug: contentItems.slug,
|
|
376
|
-
type: contentItems.type,
|
|
377
|
-
coverImageUrl: contentItems.coverImageUrl,
|
|
378
|
-
},
|
|
379
|
-
author: {
|
|
380
|
-
displayName: users.displayName,
|
|
381
|
-
username: users.username,
|
|
382
|
-
avatarUrl: users.avatarUrl,
|
|
383
|
-
},
|
|
384
|
-
})
|
|
385
|
-
.from(contestEntries)
|
|
386
|
-
.innerJoin(contentItems, eq(contestEntries.contentId, contentItems.id))
|
|
387
|
-
.innerJoin(users, eq(contestEntries.userId, users.id))
|
|
388
|
-
.where(where)
|
|
389
|
-
.orderBy(...order)
|
|
390
|
-
.limit(limit)
|
|
391
|
-
.offset(offset),
|
|
392
|
-
countRows(db, contestEntries, where),
|
|
393
|
-
]);
|
|
394
|
-
const items = rows.map((row) => {
|
|
395
|
-
const item = {
|
|
396
|
-
id: row.entry.id,
|
|
397
|
-
contestId: row.entry.contestId,
|
|
398
|
-
contentId: row.entry.contentId,
|
|
399
|
-
userId: row.entry.userId,
|
|
400
|
-
score: revealScores ? row.entry.score : null,
|
|
401
|
-
rank: row.entry.rank,
|
|
402
|
-
// The cohort outcome (advanced/eliminated) is public, but the per-round
|
|
403
|
-
// snapshot SCORE honours revealScores like the live aggregate — otherwise
|
|
404
|
-
// a judges-only/private contest leaks round scores through the snapshots.
|
|
405
|
-
// Rank stays (mirrors the always-exposed top-level rank, so winners can
|
|
406
|
-
// be announced).
|
|
407
|
-
stageState: revealScores
|
|
408
|
-
? row.entry.stageState ?? []
|
|
409
|
-
: (row.entry.stageState ?? []).map((s) => ({ ...s, score: null })),
|
|
410
|
-
eliminated: isEliminated(row.entry),
|
|
411
|
-
submittedAt: row.entry.submittedAt,
|
|
412
|
-
contentTitle: row.content.title,
|
|
413
|
-
contentSlug: row.content.slug,
|
|
414
|
-
contentType: row.content.type,
|
|
415
|
-
contentCoverImageUrl: row.content.coverImageUrl,
|
|
416
|
-
authorName: row.author.displayName ?? row.author.username,
|
|
417
|
-
authorUsername: row.author.username,
|
|
418
|
-
authorAvatarUrl: row.author.avatarUrl,
|
|
419
|
-
};
|
|
420
|
-
if (opts.includeJudgeScores) {
|
|
421
|
-
item.judgeScores = (row.entry.judgeScores ?? []);
|
|
422
|
-
}
|
|
423
|
-
if (opts.includeStageSubmissions || (opts.stageSubmissionsViewerId && row.entry.userId === opts.stageSubmissionsViewerId)) {
|
|
424
|
-
item.stageSubmissions = row.entry.stageSubmissions ?? [];
|
|
425
|
-
}
|
|
426
|
-
return item;
|
|
427
|
-
});
|
|
428
|
-
return { items, total };
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* One enriched entry by id — content + author info, per-stage artifacts, and
|
|
432
|
-
* per-judge scores. Server-internal: the route layer gates who may see the
|
|
433
|
-
* artifacts/scores (judges/owner/admin + the entrant themselves).
|
|
434
|
-
*/
|
|
435
|
-
export async function getContestEntry(db, entryId) {
|
|
436
|
-
const rows = await db
|
|
437
|
-
.select({
|
|
438
|
-
entry: contestEntries,
|
|
439
|
-
content: {
|
|
440
|
-
title: contentItems.title,
|
|
441
|
-
slug: contentItems.slug,
|
|
442
|
-
type: contentItems.type,
|
|
443
|
-
coverImageUrl: contentItems.coverImageUrl,
|
|
444
|
-
},
|
|
445
|
-
author: {
|
|
446
|
-
displayName: users.displayName,
|
|
447
|
-
username: users.username,
|
|
448
|
-
avatarUrl: users.avatarUrl,
|
|
449
|
-
},
|
|
450
|
-
})
|
|
451
|
-
.from(contestEntries)
|
|
452
|
-
.innerJoin(contentItems, eq(contestEntries.contentId, contentItems.id))
|
|
453
|
-
.innerJoin(users, eq(contestEntries.userId, users.id))
|
|
454
|
-
.where(eq(contestEntries.id, entryId))
|
|
455
|
-
.limit(1);
|
|
456
|
-
const row = rows[0];
|
|
457
|
-
if (!row)
|
|
458
|
-
return null;
|
|
459
|
-
return {
|
|
460
|
-
id: row.entry.id,
|
|
461
|
-
contestId: row.entry.contestId,
|
|
462
|
-
contentId: row.entry.contentId,
|
|
463
|
-
userId: row.entry.userId,
|
|
464
|
-
score: row.entry.score,
|
|
465
|
-
rank: row.entry.rank,
|
|
466
|
-
stageState: row.entry.stageState ?? [],
|
|
467
|
-
eliminated: isEliminated(row.entry),
|
|
468
|
-
stageSubmissions: row.entry.stageSubmissions ?? [],
|
|
469
|
-
submittedAt: row.entry.submittedAt,
|
|
470
|
-
contentTitle: row.content.title,
|
|
471
|
-
contentSlug: row.content.slug,
|
|
472
|
-
contentType: row.content.type,
|
|
473
|
-
contentCoverImageUrl: row.content.coverImageUrl,
|
|
474
|
-
authorName: row.author.displayName ?? row.author.username,
|
|
475
|
-
authorUsername: row.author.username,
|
|
476
|
-
authorAvatarUrl: row.author.avatarUrl,
|
|
477
|
-
judgeScores: (row.entry.judgeScores ?? []),
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
export async function submitContestEntry(db, contestId, contentId, userId) {
|
|
481
|
-
// Validate contest exists and is active
|
|
482
|
-
const contest = await db
|
|
483
|
-
.select({
|
|
484
|
-
id: contests.id,
|
|
485
|
-
status: contests.status,
|
|
486
|
-
eligibleContentTypes: contests.eligibleContentTypes,
|
|
487
|
-
maxEntriesPerUser: contests.maxEntriesPerUser,
|
|
488
|
-
})
|
|
489
|
-
.from(contests)
|
|
490
|
-
.where(eq(contests.id, contestId))
|
|
491
|
-
.limit(1);
|
|
492
|
-
if (contest.length === 0)
|
|
493
|
-
return null;
|
|
494
|
-
const c = contest[0];
|
|
495
|
-
if (c.status !== 'active')
|
|
496
|
-
return null;
|
|
497
|
-
// Validate content exists, is published, and user owns it
|
|
498
|
-
const content = await db
|
|
499
|
-
.select({ id: contentItems.id, authorId: contentItems.authorId, status: contentItems.status, type: contentItems.type })
|
|
500
|
-
.from(contentItems)
|
|
501
|
-
.where(eq(contentItems.id, contentId))
|
|
502
|
-
.limit(1);
|
|
503
|
-
if (content.length === 0)
|
|
504
|
-
return null;
|
|
505
|
-
if (content[0].status !== 'published')
|
|
506
|
-
return null;
|
|
507
|
-
if (content[0].authorId !== userId)
|
|
508
|
-
return null;
|
|
509
|
-
// Per-contest entry eligibility: content type must be allowed (if restricted).
|
|
510
|
-
const eligible = c.eligibleContentTypes ?? null;
|
|
511
|
-
if (eligible && eligible.length > 0 && !eligible.includes(content[0].type))
|
|
512
|
-
return null;
|
|
513
|
-
// Per-user entry cap (if set).
|
|
514
|
-
if (c.maxEntriesPerUser != null) {
|
|
515
|
-
const existingCount = await countRows(db, contestEntries, and(eq(contestEntries.contestId, contestId), eq(contestEntries.userId, userId)));
|
|
516
|
-
if (existingCount >= c.maxEntriesPerUser)
|
|
517
|
-
return null;
|
|
518
|
-
}
|
|
519
|
-
// Atomic: insert the entry and bump the denormalized entryCount together, so a
|
|
520
|
-
// duplicate (onConflictDoNothing → no row) never increments and a mid-operation
|
|
521
|
-
// failure can't leave entryCount overcounting.
|
|
522
|
-
const row = await db.transaction(async (tx) => {
|
|
523
|
-
const [inserted] = await tx
|
|
524
|
-
.insert(contestEntries)
|
|
525
|
-
.values({ contestId, contentId, userId })
|
|
526
|
-
.onConflictDoNothing()
|
|
527
|
-
.returning();
|
|
528
|
-
if (!inserted)
|
|
529
|
-
return null;
|
|
530
|
-
await tx
|
|
531
|
-
.update(contests)
|
|
532
|
-
.set({ entryCount: sql `${contests.entryCount} + 1` })
|
|
533
|
-
.where(eq(contests.id, contestId));
|
|
534
|
-
return inserted;
|
|
535
|
-
});
|
|
536
|
-
if (!row)
|
|
537
|
-
return null;
|
|
538
|
-
// Fetch enriched content + author info
|
|
539
|
-
const enriched = await db
|
|
540
|
-
.select({
|
|
541
|
-
content: {
|
|
542
|
-
title: contentItems.title,
|
|
543
|
-
slug: contentItems.slug,
|
|
544
|
-
type: contentItems.type,
|
|
545
|
-
coverImageUrl: contentItems.coverImageUrl,
|
|
546
|
-
},
|
|
547
|
-
author: {
|
|
548
|
-
displayName: users.displayName,
|
|
549
|
-
username: users.username,
|
|
550
|
-
avatarUrl: users.avatarUrl,
|
|
551
|
-
},
|
|
552
|
-
})
|
|
553
|
-
.from(contentItems)
|
|
554
|
-
.innerJoin(users, eq(contentItems.authorId, users.id))
|
|
555
|
-
.where(eq(contentItems.id, contentId))
|
|
556
|
-
.limit(1);
|
|
557
|
-
const info = enriched[0];
|
|
558
|
-
return {
|
|
559
|
-
id: row.id,
|
|
560
|
-
contestId: row.contestId,
|
|
561
|
-
contentId: row.contentId,
|
|
562
|
-
userId: row.userId,
|
|
563
|
-
score: row.score,
|
|
564
|
-
rank: row.rank,
|
|
565
|
-
stageState: [], // a freshly submitted entry is in the active cohort
|
|
566
|
-
eliminated: false,
|
|
567
|
-
submittedAt: row.submittedAt,
|
|
568
|
-
contentTitle: info?.content.title ?? 'Untitled',
|
|
569
|
-
contentSlug: info?.content.slug ?? '',
|
|
570
|
-
contentType: info?.content.type ?? 'project',
|
|
571
|
-
contentCoverImageUrl: info?.content.coverImageUrl ?? null,
|
|
572
|
-
authorName: info?.author.displayName ?? info?.author.username ?? 'Unknown',
|
|
573
|
-
authorUsername: info?.author.username ?? '',
|
|
574
|
-
authorAvatarUrl: info?.author.avatarUrl ?? null,
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
export async function judgeContestEntry(db, entryId, score, judgeId, feedback, criteriaScores) {
|
|
578
|
-
// Get the entry and its contest (read-only validation, no lock needed).
|
|
579
|
-
const existing = await db
|
|
580
|
-
.select({
|
|
581
|
-
contestStatus: contests.status,
|
|
582
|
-
contestId: contests.id,
|
|
583
|
-
entrantId: contestEntries.userId,
|
|
584
|
-
stageState: contestEntries.stageState,
|
|
585
|
-
stages: contests.stages,
|
|
586
|
-
currentStageId: contests.currentStageId,
|
|
587
|
-
startDate: contests.startDate,
|
|
588
|
-
endDate: contests.endDate,
|
|
589
|
-
judgingEndDate: contests.judgingEndDate,
|
|
590
|
-
})
|
|
591
|
-
.from(contestEntries)
|
|
592
|
-
.innerJoin(contests, eq(contestEntries.contestId, contests.id))
|
|
593
|
-
.where(eq(contestEntries.id, entryId))
|
|
594
|
-
.limit(1);
|
|
595
|
-
if (existing.length === 0)
|
|
596
|
-
return { judged: false, error: 'Entry not found' };
|
|
597
|
-
const row = existing[0];
|
|
598
|
-
// Check contest is in judging phase
|
|
599
|
-
if (row.contestStatus !== 'judging') {
|
|
600
|
-
return { judged: false, error: 'Contest is not in judging phase' };
|
|
601
|
-
}
|
|
602
|
-
// Cohort gate (Phase B2.5): once a review stage has culled the field, entries
|
|
603
|
-
// that didn't advance are out of later rounds and can't be scored.
|
|
604
|
-
if (isEliminated({ stageState: row.stageState })) {
|
|
605
|
-
return { judged: false, error: 'This entry was not advanced and can no longer be scored' };
|
|
606
|
-
}
|
|
607
|
-
// Per-round isolation: which review round is this score for? The entry's live
|
|
608
|
-
// `score` will aggregate only THIS round's judge scores (a classic contest with
|
|
609
|
-
// no explicit stages resolves to the synthesized `core-review`, so it stays one
|
|
610
|
-
// bucket — unchanged single-round behaviour).
|
|
611
|
-
const roundStage = currentStage({
|
|
612
|
-
status: row.contestStatus,
|
|
613
|
-
startDate: row.startDate,
|
|
614
|
-
endDate: row.endDate,
|
|
615
|
-
judgingEndDate: row.judgingEndDate,
|
|
616
|
-
stages: row.stages,
|
|
617
|
-
currentStageId: row.currentStageId,
|
|
618
|
-
});
|
|
619
|
-
const roundId = roundStage && roundStage.kind === 'review' ? roundStage.id : null;
|
|
620
|
-
// Conflict of interest: a judge cannot score their own entry.
|
|
621
|
-
if (row.entrantId === judgeId) {
|
|
622
|
-
return { judged: false, error: 'You cannot judge your own entry' };
|
|
623
|
-
}
|
|
624
|
-
// Check judge authorization via contestJudges table (accepted judges only)
|
|
625
|
-
const [judgeRecord] = await db
|
|
626
|
-
.select({ id: contestJudges.id, role: contestJudges.role, acceptedAt: contestJudges.acceptedAt })
|
|
627
|
-
.from(contestJudges)
|
|
628
|
-
.where(and(eq(contestJudges.contestId, row.contestId), eq(contestJudges.userId, judgeId)))
|
|
629
|
-
.limit(1);
|
|
630
|
-
if (!judgeRecord) {
|
|
631
|
-
return { judged: false, error: 'Not authorized to judge this contest' };
|
|
632
|
-
}
|
|
633
|
-
if (!judgeRecord.acceptedAt) {
|
|
634
|
-
return { judged: false, error: 'Judge invitation has not been accepted' };
|
|
635
|
-
}
|
|
636
|
-
if (judgeRecord.role === 'guest') {
|
|
637
|
-
return { judged: false, error: 'Guest judges cannot submit scores' };
|
|
638
|
-
}
|
|
639
|
-
// Derive the overall 0–100 score. When per-criterion scores are supplied, the
|
|
640
|
-
// overall is the normalized weighted sum (sum(score)/sum(max)*100), which
|
|
641
|
-
// supports any weight scheme; otherwise use the supplied overall score.
|
|
642
|
-
let overall;
|
|
643
|
-
if (criteriaScores && criteriaScores.length > 0) {
|
|
644
|
-
const totalMax = criteriaScores.reduce((s, c) => s + c.max, 0);
|
|
645
|
-
if (totalMax <= 0)
|
|
646
|
-
return { judged: false, error: 'Invalid judging criteria' };
|
|
647
|
-
if (criteriaScores.some((c) => c.score < 0 || c.score > c.max)) {
|
|
648
|
-
return { judged: false, error: 'A criterion score is out of range' };
|
|
649
|
-
}
|
|
650
|
-
overall = Math.round((criteriaScores.reduce((s, c) => s + c.score, 0) / totalMax) * 100);
|
|
651
|
-
}
|
|
652
|
-
else if (typeof score === 'number') {
|
|
653
|
-
overall = score;
|
|
654
|
-
}
|
|
655
|
-
else {
|
|
656
|
-
return { judged: false, error: 'No score provided' };
|
|
657
|
-
}
|
|
658
|
-
// Atomic read-modify-write: lock the entry row so two judges scoring the same
|
|
659
|
-
// entry concurrently can't clobber each other's judgeScores (lost update).
|
|
660
|
-
return db.transaction(async (tx) => {
|
|
661
|
-
const [locked] = await tx
|
|
662
|
-
.select({ judgeScores: contestEntries.judgeScores })
|
|
663
|
-
.from(contestEntries)
|
|
664
|
-
.where(eq(contestEntries.id, entryId))
|
|
665
|
-
.for('update');
|
|
666
|
-
const scores = (locked?.judgeScores ?? []);
|
|
667
|
-
const record = { judgeId, score: overall, feedback };
|
|
668
|
-
if (criteriaScores && criteriaScores.length > 0)
|
|
669
|
-
record.criteriaScores = criteriaScores;
|
|
670
|
-
if (roundId)
|
|
671
|
-
record.roundId = roundId;
|
|
672
|
-
// A judge has one score per round — match on judge AND round.
|
|
673
|
-
const existingIdx = scores.findIndex((s) => s.judgeId === judgeId && (s.roundId ?? null) === (roundId ?? null));
|
|
674
|
-
if (existingIdx >= 0)
|
|
675
|
-
scores[existingIdx] = record;
|
|
676
|
-
else
|
|
677
|
-
scores.push(record);
|
|
678
|
-
// The live aggregate reflects ONLY the current round's scores, so a later
|
|
679
|
-
// judging round doesn't blend with an earlier one. Earlier rounds stay in
|
|
680
|
-
// `judgeScores` (tagged with their roundId) as history.
|
|
681
|
-
const roundScores = roundId ? scores.filter((s) => (s.roundId ?? null) === roundId) : scores;
|
|
682
|
-
const avgScore = roundScores.length
|
|
683
|
-
? Math.round(roundScores.reduce((sum, s) => sum + s.score, 0) / roundScores.length)
|
|
684
|
-
: 0;
|
|
685
|
-
await tx
|
|
686
|
-
.update(contestEntries)
|
|
687
|
-
.set({ judgeScores: scores, score: avgScore })
|
|
688
|
-
.where(eq(contestEntries.id, entryId));
|
|
689
|
-
return { judged: true };
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
183
|
// --- Contest Management ---
|
|
693
184
|
export async function deleteContest(db, contestId, userId,
|
|
694
185
|
/**
|
|
@@ -727,7 +218,7 @@ function ordinalPlace(n) {
|
|
|
727
218
|
const v = n % 100;
|
|
728
219
|
return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`;
|
|
729
220
|
}
|
|
730
|
-
export async function transitionContestStatus(db, contestId, userId, newStatus) {
|
|
221
|
+
export async function transitionContestStatus(db, contestId, userId, newStatus, canManage = false) {
|
|
731
222
|
const contest = await db
|
|
732
223
|
.select({ createdById: contests.createdById, status: contests.status })
|
|
733
224
|
.from(contests)
|
|
@@ -735,23 +226,28 @@ export async function transitionContestStatus(db, contestId, userId, newStatus)
|
|
|
735
226
|
.limit(1);
|
|
736
227
|
if (contest.length === 0)
|
|
737
228
|
return { transitioned: false, error: 'Contest not found' };
|
|
738
|
-
if (contest[0].createdById !== userId)
|
|
739
|
-
return { transitioned: false, error: 'Not
|
|
229
|
+
if (contest[0].createdById !== userId && !canManage && !(await isContestEditor(db, contestId, userId))) {
|
|
230
|
+
return { transitioned: false, error: 'Not authorized to manage this contest' };
|
|
231
|
+
}
|
|
740
232
|
const currentStatus = contest[0].status;
|
|
741
233
|
const allowed = VALID_TRANSITIONS[currentStatus] ?? [];
|
|
742
234
|
if (!allowed.includes(newStatus)) {
|
|
743
235
|
return { transitioned: false, error: `Cannot transition from ${currentStatus} to ${newStatus}` };
|
|
744
236
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
+
});
|
|
755
251
|
// Notify contest entrants about status change (non-critical)
|
|
756
252
|
try {
|
|
757
253
|
const { createNotification } = await import('../notification/notification.js');
|
|
@@ -789,7 +285,7 @@ export async function transitionContestStatus(db, contestId, userId, newStatus)
|
|
|
789
285
|
createNotification(db, {
|
|
790
286
|
userId: entrant.userId,
|
|
791
287
|
type: 'contest',
|
|
792
|
-
title: '
|
|
288
|
+
title: 'You won!',
|
|
793
289
|
message: `Congratulations — you placed ${ordinalPlace(rank)} in "${contestInfo.title}"${won}!`,
|
|
794
290
|
link,
|
|
795
291
|
actorId: userId,
|
|
@@ -836,283 +332,4 @@ export async function transitionContestStatus(db, contestId, userId, newStatus)
|
|
|
836
332
|
catch { /* non-critical */ }
|
|
837
333
|
return { transitioned: true };
|
|
838
334
|
}
|
|
839
|
-
export async function withdrawContestEntry(db, entryId, userId) {
|
|
840
|
-
const existing = await db
|
|
841
|
-
.select({
|
|
842
|
-
entry: contestEntries,
|
|
843
|
-
contestStatus: contests.status,
|
|
844
|
-
})
|
|
845
|
-
.from(contestEntries)
|
|
846
|
-
.innerJoin(contests, eq(contestEntries.contestId, contests.id))
|
|
847
|
-
.where(eq(contestEntries.id, entryId))
|
|
848
|
-
.limit(1);
|
|
849
|
-
if (existing.length === 0)
|
|
850
|
-
return { withdrawn: false, error: 'Entry not found' };
|
|
851
|
-
const row = existing[0];
|
|
852
|
-
if (row.entry.userId !== userId)
|
|
853
|
-
return { withdrawn: false, error: 'Not the entry owner' };
|
|
854
|
-
if (row.contestStatus !== 'active') {
|
|
855
|
-
return { withdrawn: false, error: 'Can only withdraw from active contests' };
|
|
856
|
-
}
|
|
857
|
-
await db.delete(contestEntries).where(eq(contestEntries.id, entryId));
|
|
858
|
-
await db
|
|
859
|
-
.update(contests)
|
|
860
|
-
.set({ entryCount: sql `GREATEST(${contests.entryCount} - 1, 0)` })
|
|
861
|
-
.where(eq(contests.id, row.entry.contestId));
|
|
862
|
-
return { withdrawn: true };
|
|
863
|
-
}
|
|
864
|
-
export async function calculateContestRanks(db, contestId) {
|
|
865
|
-
// Assign ranks by score with RANK() so tied scores share a rank (1, 1, 3…).
|
|
866
|
-
// Only scored entries are ranked; entries that were never judged keep a null
|
|
867
|
-
// rank rather than being handed an arbitrary trailing position.
|
|
868
|
-
// Eliminated entries (culled at a prior review stage, Phase B2) are excluded
|
|
869
|
-
// from ranking — only the surviving cohort competes for the final placements.
|
|
870
|
-
await db.execute(sql `
|
|
871
|
-
UPDATE ${contestEntries}
|
|
872
|
-
SET rank = ranked.rn
|
|
873
|
-
FROM (
|
|
874
|
-
SELECT id, RANK() OVER (ORDER BY score DESC) AS rn
|
|
875
|
-
FROM ${contestEntries}
|
|
876
|
-
WHERE contest_id = ${contestId} AND score IS NOT NULL
|
|
877
|
-
AND NOT (stage_state @> '[{"status":"eliminated"}]'::jsonb)
|
|
878
|
-
) AS ranked
|
|
879
|
-
WHERE ${contestEntries}.id = ranked.id
|
|
880
|
-
`);
|
|
881
|
-
// Clear ranks for entries with no score (unjudged) or that were eliminated.
|
|
882
|
-
await db.execute(sql `
|
|
883
|
-
UPDATE ${contestEntries}
|
|
884
|
-
SET rank = NULL
|
|
885
|
-
WHERE contest_id = ${contestId}
|
|
886
|
-
AND (score IS NULL OR stage_state @> '[{"status":"eliminated"}]'::jsonb)
|
|
887
|
-
`);
|
|
888
|
-
}
|
|
889
|
-
/** True when an entry was culled at some review stage (Phase B2 cohort gate). */
|
|
890
|
-
export function isEliminated(entry) {
|
|
891
|
-
return !!entry.stageState?.some((s) => s.status === 'eliminated');
|
|
892
|
-
}
|
|
893
|
-
// --- Per-stage submission artifacts (proposal → prototype) ---
|
|
894
|
-
/**
|
|
895
|
-
* Validate an entrant's artifact fields against the stage's template. Pure +
|
|
896
|
-
* exhaustively testable. Domain checks, not just shape: unknown keys rejected
|
|
897
|
-
* (no smuggling values outside the template), required fields must be
|
|
898
|
-
* non-blank, and `url` fields must be real http(s) URLs — `javascript:` and
|
|
899
|
-
* friends are known-bad payloads, not "strings that look url-ish".
|
|
900
|
-
*/
|
|
901
|
-
export function validateStageArtifactFields(template, fields) {
|
|
902
|
-
const byKey = new Map(template.map((f) => [f.key, f]));
|
|
903
|
-
for (const key of Object.keys(fields)) {
|
|
904
|
-
if (!byKey.has(key))
|
|
905
|
-
return { ok: false, error: `Unknown field: ${key}` };
|
|
906
|
-
if (typeof fields[key] !== 'string')
|
|
907
|
-
return { ok: false, error: `Invalid value for ${key}` };
|
|
908
|
-
if (fields[key].length > 4000)
|
|
909
|
-
return { ok: false, error: `${byKey.get(key).label} is too long (max 4000 characters)` };
|
|
910
|
-
}
|
|
911
|
-
const clean = {};
|
|
912
|
-
for (const field of template) {
|
|
913
|
-
const raw = fields[field.key] ?? '';
|
|
914
|
-
const value = raw.trim();
|
|
915
|
-
if (!value) {
|
|
916
|
-
if (field.required)
|
|
917
|
-
return { ok: false, error: `${field.label} is required` };
|
|
918
|
-
continue; // optional + blank ⇒ omit from the snapshot
|
|
919
|
-
}
|
|
920
|
-
if (field.type === 'url') {
|
|
921
|
-
// Scheme allow-list FIRST (https?:// only), then structural URL parse.
|
|
922
|
-
if (!/^https?:\/\//i.test(value))
|
|
923
|
-
return { ok: false, error: `${field.label} must be an http(s) URL` };
|
|
924
|
-
try {
|
|
925
|
-
const u = new URL(value);
|
|
926
|
-
if (!u.hostname)
|
|
927
|
-
return { ok: false, error: `${field.label} must be a valid URL` };
|
|
928
|
-
}
|
|
929
|
-
catch {
|
|
930
|
-
return { ok: false, error: `${field.label} must be a valid URL` };
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
clean[field.key] = value;
|
|
934
|
-
}
|
|
935
|
-
return { ok: true, fields: clean };
|
|
936
|
-
}
|
|
937
|
-
/**
|
|
938
|
-
* Submit (or update) an entrant's per-stage artifact: the filled template
|
|
939
|
-
* values for one `submission` stage, snapshotted onto the entry's
|
|
940
|
-
* `stageSubmissions`. Owner-only. The stage must be the contest's CURRENT
|
|
941
|
-
* stage while the contest is `active` (status stays the gating truth — the
|
|
942
|
-
* organizer maps a later submission round back to `active` when advancing).
|
|
943
|
-
* Re-submitting while the stage is open replaces that stage's artifact.
|
|
944
|
-
* Cohort gate: an entry culled at a prior review stage can no longer submit.
|
|
945
|
-
*/
|
|
946
|
-
export async function submitStageArtifact(db, entryId, stageId, fields, userId) {
|
|
947
|
-
const fail = (error) => ({ submitted: false, error });
|
|
948
|
-
const existing = await db
|
|
949
|
-
.select({
|
|
950
|
-
entrantId: contestEntries.userId,
|
|
951
|
-
stageState: contestEntries.stageState,
|
|
952
|
-
contestStatus: contests.status,
|
|
953
|
-
stages: contests.stages,
|
|
954
|
-
currentStageId: contests.currentStageId,
|
|
955
|
-
startDate: contests.startDate,
|
|
956
|
-
endDate: contests.endDate,
|
|
957
|
-
judgingEndDate: contests.judgingEndDate,
|
|
958
|
-
})
|
|
959
|
-
.from(contestEntries)
|
|
960
|
-
.innerJoin(contests, eq(contestEntries.contestId, contests.id))
|
|
961
|
-
.where(eq(contestEntries.id, entryId))
|
|
962
|
-
.limit(1);
|
|
963
|
-
if (existing.length === 0)
|
|
964
|
-
return fail('Entry not found');
|
|
965
|
-
const row = existing[0];
|
|
966
|
-
if (row.entrantId !== userId)
|
|
967
|
-
return fail('Not the entry owner');
|
|
968
|
-
if (row.contestStatus !== 'active') {
|
|
969
|
-
return fail('Stage submissions are only open while the contest is active');
|
|
970
|
-
}
|
|
971
|
-
const source = {
|
|
972
|
-
status: row.contestStatus,
|
|
973
|
-
startDate: row.startDate,
|
|
974
|
-
endDate: row.endDate,
|
|
975
|
-
judgingEndDate: row.judgingEndDate,
|
|
976
|
-
stages: row.stages,
|
|
977
|
-
currentStageId: row.currentStageId,
|
|
978
|
-
};
|
|
979
|
-
const stages = normalizeStages(source);
|
|
980
|
-
const stage = stages.find((s) => s.id === stageId);
|
|
981
|
-
if (!stage)
|
|
982
|
-
return fail('Unknown stage');
|
|
983
|
-
if (stage.kind !== 'submission')
|
|
984
|
-
return fail('This stage does not accept submissions');
|
|
985
|
-
const template = stage.submissionTemplate ?? [];
|
|
986
|
-
if (template.length === 0)
|
|
987
|
-
return fail('This stage has no submission template');
|
|
988
|
-
const current = currentStage(source);
|
|
989
|
-
if (current?.id !== stageId)
|
|
990
|
-
return fail('This stage is not currently open');
|
|
991
|
-
// Cohort gate: once a review cut culled the field, eliminated entries are
|
|
992
|
-
// out of every later round (mirrors judgeContestEntry's gate).
|
|
993
|
-
if (isEliminated({ stageState: row.stageState })) {
|
|
994
|
-
return fail('This entry was not advanced and can no longer submit');
|
|
995
|
-
}
|
|
996
|
-
const validated = validateStageArtifactFields(template, fields);
|
|
997
|
-
if (!validated.ok)
|
|
998
|
-
return fail(validated.error);
|
|
999
|
-
// Atomic read-modify-write: lock the entry row so two concurrent saves of
|
|
1000
|
-
// the same artifact can't clobber each other (same pattern as judgeScores).
|
|
1001
|
-
return db.transaction(async (tx) => {
|
|
1002
|
-
const [locked] = await tx
|
|
1003
|
-
.select({ stageSubmissions: contestEntries.stageSubmissions })
|
|
1004
|
-
.from(contestEntries)
|
|
1005
|
-
.where(eq(contestEntries.id, entryId))
|
|
1006
|
-
.for('update');
|
|
1007
|
-
const submissions = (locked?.stageSubmissions ?? []);
|
|
1008
|
-
const record = { stageId, fields: validated.fields, submittedAt: new Date().toISOString() };
|
|
1009
|
-
const idx = submissions.findIndex((s) => s.stageId === stageId);
|
|
1010
|
-
if (idx >= 0)
|
|
1011
|
-
submissions[idx] = record;
|
|
1012
|
-
else
|
|
1013
|
-
submissions.push(record);
|
|
1014
|
-
await tx
|
|
1015
|
-
.update(contestEntries)
|
|
1016
|
-
.set({ stageSubmissions: submissions })
|
|
1017
|
-
.where(eq(contestEntries.id, entryId));
|
|
1018
|
-
return { submitted: true, stageSubmissions: submissions };
|
|
1019
|
-
});
|
|
1020
|
-
}
|
|
1021
|
-
/**
|
|
1022
|
-
* Phase B2 — apply an advancement cut at a review stage: the surviving cohort
|
|
1023
|
-
* (entries not already eliminated) is split into advancers + eliminated, the
|
|
1024
|
-
* round's score/rank is snapshotted into each entry's `stageState`, and the
|
|
1025
|
-
* contest's `currentStageId` moves to the next stage. Idempotent per stage —
|
|
1026
|
-
* re-running replaces that stage's `stageState` rows rather than duplicating them.
|
|
1027
|
-
* Owner-gated. `topN` ties broken by score → rank → id for determinism.
|
|
1028
|
-
*/
|
|
1029
|
-
export async function advanceContestStage(db, contestId, userId, input) {
|
|
1030
|
-
const fail = (error) => ({ advanced: false, advancedCount: 0, eliminatedCount: 0, error });
|
|
1031
|
-
const [contest] = await db
|
|
1032
|
-
.select({
|
|
1033
|
-
createdById: contests.createdById,
|
|
1034
|
-
status: contests.status,
|
|
1035
|
-
stages: contests.stages,
|
|
1036
|
-
currentStageId: contests.currentStageId,
|
|
1037
|
-
startDate: contests.startDate,
|
|
1038
|
-
endDate: contests.endDate,
|
|
1039
|
-
judgingEndDate: contests.judgingEndDate,
|
|
1040
|
-
})
|
|
1041
|
-
.from(contests)
|
|
1042
|
-
.where(eq(contests.id, contestId))
|
|
1043
|
-
.limit(1);
|
|
1044
|
-
if (!contest)
|
|
1045
|
-
return fail('Contest not found');
|
|
1046
|
-
if (contest.createdById !== userId)
|
|
1047
|
-
return fail('Not the contest owner');
|
|
1048
|
-
const stages = normalizeStages(contest);
|
|
1049
|
-
const idx = stages.findIndex((s) => s.id === input.reviewStageId);
|
|
1050
|
-
if (idx < 0)
|
|
1051
|
-
return fail('Unknown stage');
|
|
1052
|
-
if (stages[idx].kind !== 'review')
|
|
1053
|
-
return fail('Advancement applies to review stages only');
|
|
1054
|
-
const rows = await db
|
|
1055
|
-
.select({ id: contestEntries.id, userId: contestEntries.userId, score: contestEntries.score, rank: contestEntries.rank, stageState: contestEntries.stageState })
|
|
1056
|
-
.from(contestEntries)
|
|
1057
|
-
.where(eq(contestEntries.contestId, contestId));
|
|
1058
|
-
// Only the running cohort (not already eliminated) is subject to the cut.
|
|
1059
|
-
const eligible = rows.filter((r) => !isEliminated(r));
|
|
1060
|
-
let advancedIds;
|
|
1061
|
-
if (input.mode === 'manual') {
|
|
1062
|
-
const picked = new Set(input.advancedEntryIds ?? []);
|
|
1063
|
-
advancedIds = new Set(eligible.filter((e) => picked.has(e.id)).map((e) => e.id));
|
|
1064
|
-
}
|
|
1065
|
-
else {
|
|
1066
|
-
const n = Math.max(0, input.topN ?? 0);
|
|
1067
|
-
const sorted = [...eligible].sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity) ||
|
|
1068
|
-
(a.rank ?? Infinity) - (b.rank ?? Infinity) ||
|
|
1069
|
-
a.id.localeCompare(b.id));
|
|
1070
|
-
advancedIds = new Set(sorted.slice(0, n).map((e) => e.id));
|
|
1071
|
-
}
|
|
1072
|
-
let advancedCount = 0;
|
|
1073
|
-
let eliminatedCount = 0;
|
|
1074
|
-
for (const e of eligible) {
|
|
1075
|
-
const isAdv = advancedIds.has(e.id);
|
|
1076
|
-
const prior = (e.stageState ?? []).filter((s) => s.stageId !== input.reviewStageId);
|
|
1077
|
-
const next = [...prior, { stageId: input.reviewStageId, status: isAdv ? 'advanced' : 'eliminated', score: e.score ?? null, rank: e.rank ?? null }];
|
|
1078
|
-
await db.update(contestEntries).set({ stageState: next }).where(eq(contestEntries.id, e.id));
|
|
1079
|
-
if (isAdv)
|
|
1080
|
-
advancedCount++;
|
|
1081
|
-
else
|
|
1082
|
-
eliminatedCount++;
|
|
1083
|
-
}
|
|
1084
|
-
const nextStage = stages[idx + 1];
|
|
1085
|
-
if (nextStage) {
|
|
1086
|
-
await db.update(contests).set({ currentStageId: nextStage.id, updatedAt: new Date() }).where(eq(contests.id, contestId));
|
|
1087
|
-
}
|
|
1088
|
-
// Notify entrants of the outcome (non-critical, de-duped by user).
|
|
1089
|
-
try {
|
|
1090
|
-
const { createNotification } = await import('../notification/notification.js');
|
|
1091
|
-
const [info] = await db.select({ title: contests.title, slug: contests.slug }).from(contests).where(eq(contests.id, contestId)).limit(1);
|
|
1092
|
-
if (info) {
|
|
1093
|
-
const nextName = nextStage?.name ?? 'the next stage';
|
|
1094
|
-
const seen = new Set();
|
|
1095
|
-
for (const e of eligible) {
|
|
1096
|
-
if (seen.has(e.userId))
|
|
1097
|
-
continue;
|
|
1098
|
-
seen.add(e.userId);
|
|
1099
|
-
const adv = advancedIds.has(e.id);
|
|
1100
|
-
createNotification(db, {
|
|
1101
|
-
userId: e.userId,
|
|
1102
|
-
type: 'contest',
|
|
1103
|
-
title: adv ? '✅ You advanced!' : 'Contest update',
|
|
1104
|
-
message: adv
|
|
1105
|
-
? `Your entry advanced to ${nextName} in "${info.title}".`
|
|
1106
|
-
: `Your entry wasn't selected to continue in "${info.title}".`,
|
|
1107
|
-
link: `/contests/${info.slug}`,
|
|
1108
|
-
actorId: userId,
|
|
1109
|
-
}).catch(() => { });
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
catch {
|
|
1114
|
-
/* non-critical */
|
|
1115
|
-
}
|
|
1116
|
-
return { advanced: true, advancedCount, eliminatedCount };
|
|
1117
|
-
}
|
|
1118
335
|
//# sourceMappingURL=contest.js.map
|