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