@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.
Files changed (183) hide show
  1. package/dist/admin/admin.d.ts +9 -0
  2. package/dist/admin/admin.d.ts.map +1 -1
  3. package/dist/admin/admin.js +50 -13
  4. package/dist/admin/admin.js.map +1 -1
  5. package/dist/content/content.d.ts +0 -5
  6. package/dist/content/content.d.ts.map +1 -1
  7. package/dist/content/content.js +118 -122
  8. package/dist/content/content.js.map +1 -1
  9. package/dist/content/index.d.ts +1 -1
  10. package/dist/content/index.d.ts.map +1 -1
  11. package/dist/content/index.js +1 -1
  12. package/dist/content/index.js.map +1 -1
  13. package/dist/contest/contest.d.ts +7 -300
  14. package/dist/contest/contest.d.ts.map +1 -1
  15. package/dist/contest/contest.js +99 -882
  16. package/dist/contest/contest.js.map +1 -1
  17. package/dist/contest/entries.d.ts +41 -0
  18. package/dist/contest/entries.d.ts.map +1 -0
  19. package/dist/contest/entries.js +285 -0
  20. package/dist/contest/entries.js.map +1 -0
  21. package/dist/contest/export.d.ts +20 -0
  22. package/dist/contest/export.d.ts.map +1 -0
  23. package/dist/contest/export.js +131 -0
  24. package/dist/contest/export.js.map +1 -0
  25. package/dist/contest/index.d.ts +12 -3
  26. package/dist/contest/index.d.ts.map +1 -1
  27. package/dist/contest/index.js +9 -2
  28. package/dist/contest/index.js.map +1 -1
  29. package/dist/contest/judges.js +9 -8
  30. package/dist/contest/judges.js.map +1 -1
  31. package/dist/contest/judging.d.ts +38 -0
  32. package/dist/contest/judging.d.ts.map +1 -0
  33. package/dist/contest/judging.js +274 -0
  34. package/dist/contest/judging.js.map +1 -0
  35. package/dist/contest/read.d.ts +44 -0
  36. package/dist/contest/read.d.ts.map +1 -0
  37. package/dist/contest/read.js +164 -0
  38. package/dist/contest/read.js.map +1 -0
  39. package/dist/contest/stages.d.ts +28 -0
  40. package/dist/contest/stages.d.ts.map +1 -0
  41. package/dist/contest/stages.js +52 -0
  42. package/dist/contest/stages.js.map +1 -0
  43. package/dist/contest/stakeholders.d.ts +32 -7
  44. package/dist/contest/stakeholders.d.ts.map +1 -1
  45. package/dist/contest/stakeholders.js +55 -14
  46. package/dist/contest/stakeholders.js.map +1 -1
  47. package/dist/contest/submissions.d.ts +90 -0
  48. package/dist/contest/submissions.d.ts.map +1 -0
  49. package/dist/contest/submissions.js +275 -0
  50. package/dist/contest/submissions.js.map +1 -0
  51. package/dist/contest/types.d.ts +197 -0
  52. package/dist/contest/types.d.ts.map +1 -0
  53. package/dist/contest/types.js +2 -0
  54. package/dist/contest/types.js.map +1 -0
  55. package/dist/contest/validation.d.ts +32 -0
  56. package/dist/contest/validation.d.ts.map +1 -0
  57. package/dist/contest/validation.js +132 -0
  58. package/dist/contest/validation.js.map +1 -0
  59. package/dist/docs/docs.d.ts +9 -3
  60. package/dist/docs/docs.d.ts.map +1 -1
  61. package/dist/docs/docs.js +16 -6
  62. package/dist/docs/docs.js.map +1 -1
  63. package/dist/events/events.d.ts.map +1 -1
  64. package/dist/events/events.js +12 -6
  65. package/dist/events/events.js.map +1 -1
  66. package/dist/federation/activityDedup.d.ts +14 -0
  67. package/dist/federation/activityDedup.d.ts.map +1 -0
  68. package/dist/federation/activityDedup.js +34 -0
  69. package/dist/federation/activityDedup.js.map +1 -0
  70. package/dist/federation/assertPublicHost.d.ts +14 -0
  71. package/dist/federation/assertPublicHost.d.ts.map +1 -0
  72. package/dist/federation/assertPublicHost.js +62 -0
  73. package/dist/federation/assertPublicHost.js.map +1 -0
  74. package/dist/federation/delivery.d.ts.map +1 -1
  75. package/dist/federation/delivery.js +37 -51
  76. package/dist/federation/delivery.js.map +1 -1
  77. package/dist/federation/federation.d.ts.map +1 -1
  78. package/dist/federation/federation.js +11 -7
  79. package/dist/federation/federation.js.map +1 -1
  80. package/dist/federation/hubMirroring.d.ts.map +1 -1
  81. package/dist/federation/hubMirroring.js +85 -66
  82. package/dist/federation/hubMirroring.js.map +1 -1
  83. package/dist/federation/inboxHandlers.d.ts.map +1 -1
  84. package/dist/federation/inboxHandlers.js +84 -73
  85. package/dist/federation/inboxHandlers.js.map +1 -1
  86. package/dist/federation/inboxParsing.d.ts +28 -0
  87. package/dist/federation/inboxParsing.d.ts.map +1 -0
  88. package/dist/federation/inboxParsing.js +71 -0
  89. package/dist/federation/inboxParsing.js.map +1 -0
  90. package/dist/federation/index.d.ts +2 -0
  91. package/dist/federation/index.d.ts.map +1 -1
  92. package/dist/federation/index.js +2 -0
  93. package/dist/federation/index.js.map +1 -1
  94. package/dist/federation/mastodonLogin.d.ts.map +1 -1
  95. package/dist/federation/mastodonLogin.js +19 -0
  96. package/dist/federation/mastodonLogin.js.map +1 -1
  97. package/dist/federation/outboxQueries.js +1 -1
  98. package/dist/federation/outboxQueries.js.map +1 -1
  99. package/dist/federation/timeline.d.ts +11 -0
  100. package/dist/federation/timeline.d.ts.map +1 -1
  101. package/dist/federation/timeline.js +101 -69
  102. package/dist/federation/timeline.js.map +1 -1
  103. package/dist/hub/hub.d.ts.map +1 -1
  104. package/dist/hub/hub.js +41 -3
  105. package/dist/hub/hub.js.map +1 -1
  106. package/dist/hub/index.d.ts +1 -1
  107. package/dist/hub/index.d.ts.map +1 -1
  108. package/dist/hub/index.js +1 -1
  109. package/dist/hub/index.js.map +1 -1
  110. package/dist/hub/members.d.ts +19 -0
  111. package/dist/hub/members.d.ts.map +1 -1
  112. package/dist/hub/members.js +158 -13
  113. package/dist/hub/members.js.map +1 -1
  114. package/dist/hub/moderation.d.ts +1 -1
  115. package/dist/hub/moderation.d.ts.map +1 -1
  116. package/dist/hub/moderation.js +25 -11
  117. package/dist/hub/moderation.js.map +1 -1
  118. package/dist/hub/posts.d.ts.map +1 -1
  119. package/dist/hub/posts.js +18 -10
  120. package/dist/hub/posts.js.map +1 -1
  121. package/dist/hub/resources.js +2 -2
  122. package/dist/hub/resources.js.map +1 -1
  123. package/dist/identity/mastodonFactory.d.ts.map +1 -1
  124. package/dist/identity/mastodonFactory.js +7 -0
  125. package/dist/identity/mastodonFactory.js.map +1 -1
  126. package/dist/index.d.ts +8 -8
  127. package/dist/index.d.ts.map +1 -1
  128. package/dist/index.js +6 -6
  129. package/dist/index.js.map +1 -1
  130. package/dist/learning/learning.d.ts.map +1 -1
  131. package/dist/learning/learning.js +42 -22
  132. package/dist/learning/learning.js.map +1 -1
  133. package/dist/messaging/messaging.d.ts.map +1 -1
  134. package/dist/messaging/messaging.js +3 -3
  135. package/dist/messaging/messaging.js.map +1 -1
  136. package/dist/notification/notification.d.ts.map +1 -1
  137. package/dist/notification/notification.js +4 -2
  138. package/dist/notification/notification.js.map +1 -1
  139. package/dist/product/product.d.ts.map +1 -1
  140. package/dist/product/product.js +75 -37
  141. package/dist/product/product.js.map +1 -1
  142. package/dist/profile/index.d.ts +2 -1
  143. package/dist/profile/index.d.ts.map +1 -1
  144. package/dist/profile/index.js +1 -1
  145. package/dist/profile/index.js.map +1 -1
  146. package/dist/profile/profile.d.ts +24 -2
  147. package/dist/profile/profile.d.ts.map +1 -1
  148. package/dist/profile/profile.js +34 -8
  149. package/dist/profile/profile.js.map +1 -1
  150. package/dist/query.d.ts +18 -2
  151. package/dist/query.d.ts.map +1 -1
  152. package/dist/query.js +24 -6
  153. package/dist/query.js.map +1 -1
  154. package/dist/rbac/admin.d.ts +43 -0
  155. package/dist/rbac/admin.d.ts.map +1 -0
  156. package/dist/rbac/admin.js +172 -0
  157. package/dist/rbac/admin.js.map +1 -0
  158. package/dist/rbac/index.d.ts +4 -0
  159. package/dist/rbac/index.d.ts.map +1 -1
  160. package/dist/rbac/index.js +2 -0
  161. package/dist/rbac/index.js.map +1 -1
  162. package/dist/rbac/seed.d.ts +30 -0
  163. package/dist/rbac/seed.d.ts.map +1 -0
  164. package/dist/rbac/seed.js +75 -0
  165. package/dist/rbac/seed.js.map +1 -0
  166. package/dist/search/contentSearch.d.ts +1 -1
  167. package/dist/search/contentSearch.d.ts.map +1 -1
  168. package/dist/search/contentSearch.js +22 -12
  169. package/dist/search/contentSearch.js.map +1 -1
  170. package/dist/social/social.d.ts +4 -2
  171. package/dist/social/social.d.ts.map +1 -1
  172. package/dist/social/social.js +25 -8
  173. package/dist/social/social.js.map +1 -1
  174. package/dist/types.d.ts +3 -0
  175. package/dist/types.d.ts.map +1 -1
  176. package/dist/video/video.d.ts +3 -0
  177. package/dist/video/video.d.ts.map +1 -1
  178. package/dist/video/video.js +17 -13
  179. package/dist/video/video.js.map +1 -1
  180. package/dist/voting/voting.d.ts.map +1 -1
  181. package/dist/voting/voting.js +39 -1
  182. package/dist/voting/voting.js.map +1 -1
  183. package/package.json +9 -7
@@ -1,187 +1,12 @@
1
- import { eq, ne, and, or, desc, sql, isNotNull, inArray } from 'drizzle-orm';
2
- import { contests, contestEntries, contestJudges, contestStakeholders, users, contentItems } from '@commonpub/schema';
3
- import { normalizePagination, countRows } from '../query.js';
4
- import { isContestStakeholder } from './stakeholders.js';
5
- import { isContestJudge } from './judges.js';
6
- const toIso = (d) => d ? new Date(d).toISOString() : undefined;
7
- /**
8
- * The classic Submissions Judging Results timeline, synthesized from the
9
- * status + date columns for contests that haven't defined explicit stages.
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
- const [row] = await db
207
- .insert(contests)
208
- .values({
209
- title: input.title,
210
- slug: input.slug,
211
- subheading: input.subheading ?? null,
212
- description: input.description ?? null,
213
- rules: input.rules ?? null,
214
- prizesDescription: input.prizesDescription ?? null,
215
- descriptionFormat: input.descriptionFormat ?? 'markdown',
216
- rulesFormat: input.rulesFormat ?? 'markdown',
217
- prizesDescriptionFormat: input.prizesDescriptionFormat ?? 'markdown',
218
- showPrizes: input.showPrizes ?? true,
219
- stages: input.stages ?? [],
220
- // Only keep currentStageId if it references a stage that actually exists.
221
- currentStageId: input.currentStageId && (input.stages ?? []).some((s) => s.id === input.currentStageId) ? input.currentStageId : null,
222
- bannerUrl: input.bannerUrl ?? null,
223
- coverImageUrl: input.coverImageUrl ?? null,
224
- prizes: input.prizes ?? null,
225
- judgingCriteria: input.judgingCriteria ?? null,
226
- communityVotingEnabled: input.communityVotingEnabled ?? false,
227
- judgingVisibility: input.judgingVisibility ?? 'judges-only',
228
- eligibleContentTypes: input.eligibleContentTypes ?? null,
229
- maxEntriesPerUser: input.maxEntriesPerUser ?? null,
230
- visibility: input.visibility ?? 'public',
231
- visibleToRoles: input.visibleToRoles ?? null,
232
- startDate: new Date(input.startDate),
233
- endDate: new Date(input.endDate),
234
- judgingEndDate: input.judgingEndDate ? new Date(input.judgingEndDate) : null,
235
- createdById: input.createdBy,
236
- })
237
- .returning();
238
- // Single source of truth: seed the contest_judges table from any judge IDs
239
- // provided at creation. The legacy `judges` jsonb column is no longer written
240
- // or read — authorization + display use the table exclusively.
241
- if (input.judges && input.judges.length > 0) {
242
- await db
243
- .insert(contestJudges)
244
- .values(input.judges.map((userId) => ({ contestId: row.id, userId })))
245
- .onConflictDoNothing();
246
- }
247
- // Seed stakeholders (view-only reviewers) from create input.
248
- if (input.stakeholders && input.stakeholders.length > 0) {
249
- await db
250
- .insert(contestStakeholders)
251
- .values(input.stakeholders.map((userId) => ({ contestId: row.id, userId })))
252
- .onConflictDoNothing();
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
- export async function updateContest(db, slug, userId, data) {
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
- if (existing[0].createdById !== userId)
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 the contest owner' };
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
- await db
746
- .update(contests)
747
- .set({
748
- status: newStatus,
749
- updatedAt: new Date(),
750
- })
751
- .where(eq(contests.id, contestId));
752
- if (newStatus === 'completed') {
753
- await calculateContestRanks(db, contestId);
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: '🏆 You won!',
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