@commonpub/layer 0.28.1 → 0.29.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.
@@ -0,0 +1,126 @@
1
+ <script setup lang="ts">
2
+ import type { ContestStakeholderItem } from '@commonpub/server';
3
+
4
+ const props = defineProps<{ contestSlug: string }>();
5
+
6
+ const toast = useToast();
7
+ const { data: stakeholders, refresh } = useLazyFetch<ContestStakeholderItem[]>(
8
+ `/api/contests/${props.contestSlug}/stakeholders`,
9
+ );
10
+
11
+ const searchQuery = ref('');
12
+ const searchResults = ref<Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>>([]);
13
+ const searching = ref(false);
14
+ const adding = ref(false);
15
+ let searchTimeout: ReturnType<typeof setTimeout> | null = null;
16
+
17
+ function handleSearch(): void {
18
+ if (searchTimeout) clearTimeout(searchTimeout);
19
+ if (!searchQuery.value || searchQuery.value.length < 2) { searchResults.value = []; return; }
20
+ searchTimeout = setTimeout(async () => {
21
+ searching.value = true;
22
+ try {
23
+ const data = await ($fetch as Function)('/api/admin/users', { query: { search: searchQuery.value, limit: 8 } }) as { items: Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }> };
24
+ const existing = new Set((stakeholders.value ?? []).map((s) => s.userId));
25
+ searchResults.value = data.items.filter((u) => !existing.has(u.id));
26
+ } catch {
27
+ searchResults.value = [];
28
+ } finally {
29
+ searching.value = false;
30
+ }
31
+ }, 300);
32
+ }
33
+
34
+ async function addStakeholder(userId: string): Promise<void> {
35
+ adding.value = true;
36
+ try {
37
+ await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders`, { method: 'POST', body: { userId } });
38
+ toast.success('Reviewer added');
39
+ searchQuery.value = '';
40
+ searchResults.value = [];
41
+ await refresh();
42
+ } catch {
43
+ toast.error('Failed to add reviewer');
44
+ } finally {
45
+ adding.value = false;
46
+ }
47
+ }
48
+
49
+ async function removeStakeholder(userId: string): Promise<void> {
50
+ if (!confirm('Remove this reviewer’s access?')) return;
51
+ try {
52
+ await ($fetch as Function)(`/api/contests/${props.contestSlug}/stakeholders/${userId}`, { method: 'DELETE' });
53
+ toast.success('Reviewer removed');
54
+ await refresh();
55
+ } catch {
56
+ toast.error('Failed to remove reviewer');
57
+ }
58
+ }
59
+ </script>
60
+
61
+ <template>
62
+ <div class="cpub-sh">
63
+ <p class="cpub-sh-hint">Reviewers can view this contest (even while private/unpublished) but can't edit it or judge.</p>
64
+ <div v-if="stakeholders?.length" class="cpub-sh-list">
65
+ <div v-for="s in stakeholders" :key="s.id" class="cpub-sh-row">
66
+ <NuxtLink :to="`/u/${s.userUsername}`" class="cpub-sh-link">
67
+ <span class="cpub-sh-av">
68
+ <img v-if="s.userAvatar" :src="s.userAvatar" :alt="s.userName" />
69
+ <span v-else>{{ s.userName.charAt(0) }}</span>
70
+ </span>
71
+ <span class="cpub-sh-name">{{ s.userName }}</span>
72
+ </NuxtLink>
73
+ <button class="cpub-sh-remove" :aria-label="`Remove ${s.userName}`" @click="removeStakeholder(s.userId)">
74
+ <i class="fa-solid fa-xmark"></i>
75
+ </button>
76
+ </div>
77
+ </div>
78
+ <p v-else class="cpub-sh-empty">No reviewers yet.</p>
79
+
80
+ <div class="cpub-sh-search">
81
+ <input
82
+ v-model="searchQuery"
83
+ class="cpub-sh-input"
84
+ placeholder="Search users by name or username..."
85
+ aria-label="Search users to add as reviewers"
86
+ @input="handleSearch"
87
+ />
88
+ <div v-if="searchResults.length" class="cpub-sh-dropdown">
89
+ <button v-for="u in searchResults" :key="u.id" class="cpub-sh-option" :disabled="adding" @click="addStakeholder(u.id)">
90
+ <span class="cpub-sh-av cpub-sh-av-sm">
91
+ <img v-if="u.avatarUrl" :src="u.avatarUrl" :alt="u.displayName || u.username" />
92
+ <span v-else>{{ (u.displayName || u.username).charAt(0) }}</span>
93
+ </span>
94
+ <span class="cpub-sh-opt-name">{{ u.displayName || u.username }}</span>
95
+ <span class="cpub-sh-opt-handle">@{{ u.username }}</span>
96
+ </button>
97
+ </div>
98
+ <div v-else-if="searching" class="cpub-sh-dropdown"><span class="cpub-sh-dropdown-empty">Searching...</span></div>
99
+ <div v-else-if="searchQuery.length >= 2" class="cpub-sh-dropdown"><span class="cpub-sh-dropdown-empty">No users found</span></div>
100
+ </div>
101
+ </div>
102
+ </template>
103
+
104
+ <style scoped>
105
+ .cpub-sh-hint { font-size: 11px; color: var(--text-faint); margin: 0 0 12px; line-height: 1.5; }
106
+ .cpub-sh-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
107
+ .cpub-sh-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
108
+ .cpub-sh-link { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); flex: 1; min-width: 0; }
109
+ .cpub-sh-av { width: 24px; height: 24px; border-radius: 50%; background: var(--surface2); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; overflow: hidden; flex-shrink: 0; }
110
+ .cpub-sh-av img { width: 100%; height: 100%; object-fit: cover; }
111
+ .cpub-sh-av-sm { width: 20px; height: 20px; font-size: 8px; }
112
+ .cpub-sh-name { font-size: 12px; font-weight: 600; }
113
+ .cpub-sh-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 6px; min-height: 28px; }
114
+ .cpub-sh-remove:hover { color: var(--red); }
115
+ .cpub-sh-empty { font-size: 12px; color: var(--text-faint); font-style: italic; margin-bottom: 12px; }
116
+ .cpub-sh-search { position: relative; }
117
+ .cpub-sh-input { font-size: 12px; padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; width: 100%; }
118
+ .cpub-sh-input:focus { border-color: var(--accent); }
119
+ .cpub-sh-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); margin-top: 2px; max-height: 200px; overflow-y: auto; }
120
+ .cpub-sh-option { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: none; border: none; width: 100%; text-align: left; cursor: pointer; }
121
+ .cpub-sh-option:hover { background: var(--surface2); }
122
+ .cpub-sh-option:disabled { opacity: 0.5; cursor: default; }
123
+ .cpub-sh-opt-name { font-size: 12px; font-weight: 600; color: var(--text); }
124
+ .cpub-sh-opt-handle { font-size: 11px; color: var(--text-faint); margin-left: auto; }
125
+ .cpub-sh-dropdown-empty { display: block; padding: 8px 12px; font-size: 11px; color: var(--text-faint); }
126
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -53,16 +53,16 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
+ "@commonpub/auth": "0.6.0",
56
57
  "@commonpub/config": "0.15.0",
57
58
  "@commonpub/docs": "0.6.3",
58
59
  "@commonpub/explainer": "0.7.15",
59
- "@commonpub/protocol": "0.12.0",
60
60
  "@commonpub/learning": "0.5.2",
61
- "@commonpub/schema": "0.21.0",
61
+ "@commonpub/schema": "0.22.0",
62
+ "@commonpub/server": "2.63.0",
62
63
  "@commonpub/ui": "0.9.1",
63
64
  "@commonpub/editor": "0.7.11",
64
- "@commonpub/server": "2.62.0",
65
- "@commonpub/auth": "0.6.0"
65
+ "@commonpub/protocol": "0.12.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -31,6 +31,15 @@ function toggleType(type: string): void {
31
31
  else eligibleContentTypes.value.push(type);
32
32
  }
33
33
 
34
+ const visibility = ref<'public' | 'unlisted' | 'private'>('public');
35
+ const visibleToRoles = ref<string[]>([]);
36
+ const ROLE_OPTIONS = ['member', 'pro', 'verified', 'staff', 'admin'];
37
+ function toggleRole(r: string): void {
38
+ const i = visibleToRoles.value.indexOf(r);
39
+ if (i >= 0) visibleToRoles.value.splice(i, 1);
40
+ else visibleToRoles.value.push(r);
41
+ }
42
+
34
43
  interface Prize { place: number | null; category: string; title: string; description: string; value: string }
35
44
  const prizes = ref<Prize[]>([]);
36
45
 
@@ -51,6 +60,8 @@ watch(contest, (c) => {
51
60
  judgingVisibility.value = (c.judgingVisibility as typeof judgingVisibility.value) ?? 'judges-only';
52
61
  eligibleContentTypes.value = [...(c.eligibleContentTypes ?? [])];
53
62
  maxEntriesPerUser.value = c.maxEntriesPerUser ?? null;
63
+ visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
64
+ visibleToRoles.value = [...(c.visibleToRoles ?? [])];
54
65
  prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title: string; description?: string; value?: string }) => ({
55
66
  place: p.place ?? null,
56
67
  category: p.category ?? '',
@@ -130,6 +141,8 @@ async function handleSave(): Promise<void> {
130
141
  judgingVisibility: judgingVisibility.value,
131
142
  eligibleContentTypes: eligibleContentTypes.value,
132
143
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
144
+ visibility: visibility.value,
145
+ visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
133
146
  prizes: prizeData,
134
147
  judgingCriteria: criteriaData,
135
148
  },
@@ -300,6 +313,32 @@ async function transitionStatus(newStatus: string): Promise<void> {
300
313
  </div>
301
314
  </section>
302
315
 
316
+ <!-- Visibility & Access -->
317
+ <section class="cpub-form-section">
318
+ <h2 class="cpub-form-section-title">Visibility &amp; Access</h2>
319
+ <div class="cpub-form-field">
320
+ <label class="cpub-form-label">Who can see this contest</label>
321
+ <select v-model="visibility" class="cpub-form-input">
322
+ <option value="public">Public — listed and visible to everyone</option>
323
+ <option value="unlisted">Unlisted — visible by direct link, hidden from listings</option>
324
+ <option value="private">Private — restricted</option>
325
+ </select>
326
+ </div>
327
+ <div v-if="visibility === 'private'" class="cpub-form-field">
328
+ <span class="cpub-form-label">Also visible to roles</span>
329
+ <div class="cpub-type-options" role="group" aria-label="Roles that can view">
330
+ <label v-for="r in ROLE_OPTIONS" :key="r" class="cpub-form-check">
331
+ <input type="checkbox" :checked="visibleToRoles.includes(r)" @change="toggleRole(r)" />
332
+ <span>{{ r }}</span>
333
+ </label>
334
+ </div>
335
+ </div>
336
+ <div class="cpub-subhead">
337
+ <h3 class="cpub-form-subtitle">Reviewers</h3>
338
+ </div>
339
+ <ContestStakeholderManager :contest-slug="slug" />
340
+ </section>
341
+
303
342
  <!-- Judge panel (single source of truth: contest_judges table) -->
304
343
  <section class="cpub-form-section">
305
344
  <h2 class="cpub-form-section-title">Judges</h2>
@@ -27,6 +27,25 @@ const myJudge = computed(() => judges.value.find((j) => j.userId === user.value?
27
27
  const pendingInvite = computed(() => !!myJudge.value && !myJudge.value.acceptedAt);
28
28
  const canJudge = computed(() => !!myJudge.value && !!myJudge.value.acceptedAt && myJudge.value.role !== 'guest');
29
29
 
30
+ // Unique entrants (the people), distinct from entries (the submissions).
31
+ interface Participant { username: string; name: string; avatar: string | null; count: number }
32
+ const participants = computed<Participant[]>(() => {
33
+ const map = new Map<string, Participant>();
34
+ for (const e of entries.value) {
35
+ const cur = map.get(e.authorUsername);
36
+ if (cur) cur.count++;
37
+ else map.set(e.authorUsername, { username: e.authorUsername, name: e.authorName, avatar: e.authorAvatarUrl, count: 1 });
38
+ }
39
+ return [...map.values()];
40
+ });
41
+
42
+ // Visibility banner shown to those who can see a non-public contest.
43
+ const visibilityNote = computed(() => {
44
+ if (!c.value || c.value.visibility === 'public') return null;
45
+ if (c.value.visibility === 'unlisted') return { icon: 'fa-link', text: 'Unlisted — visible by direct link only, hidden from listings.' };
46
+ return { icon: 'fa-lock', text: 'Private — visible only to you, reviewers, judges, and allowed roles.' };
47
+ });
48
+
30
49
  // Tabs ----------------------------------------------------------------------
31
50
  interface Tab { key: string; label: string; icon: string; count?: number }
32
51
  const tabs = computed<Tab[]>(() => {
@@ -34,6 +53,7 @@ const tabs = computed<Tab[]>(() => {
34
53
  if (c.value?.rules) t.push({ key: 'rules', label: 'Rules', icon: 'fa-file-lines' });
35
54
  if (c.value?.prizes?.length) t.push({ key: 'prizes', label: 'Prizes', icon: 'fa-trophy' });
36
55
  t.push({ key: 'entries', label: 'Entries', icon: 'fa-box-open', count: c.value?.entryCount ?? entries.value.length });
56
+ if (participants.value.length) t.push({ key: 'participants', label: 'Participants', icon: 'fa-users', count: participants.value.length });
37
57
  if (judges.value.length || isOwner.value) t.push({ key: 'judges', label: 'Judges', icon: 'fa-gavel', count: judges.value.length || undefined });
38
58
  return t;
39
59
  });
@@ -207,6 +227,12 @@ async function withdrawEntry(entryId: string): Promise<void> {
207
227
  </button>
208
228
  </div>
209
229
 
230
+ <!-- Visibility banner (non-public contests, shown to those who can see it) -->
231
+ <div v-if="visibilityNote" class="cpub-visibility-banner">
232
+ <i class="fa-solid" :class="visibilityNote.icon"></i>
233
+ <span>{{ visibilityNote.text }}</span>
234
+ </div>
235
+
210
236
  <!-- Tab bar -->
211
237
  <div class="cpub-tabbar" role="tablist" aria-label="Contest sections">
212
238
  <button
@@ -261,6 +287,23 @@ async function withdrawEntry(entryId: string): Promise<void> {
261
287
  />
262
288
  </div>
263
289
 
290
+ <!-- PARTICIPANTS -->
291
+ <div v-show="activeTab === 'participants'" id="cpub-panel-participants" role="tabpanel" aria-labelledby="cpub-tab-participants" tabindex="0">
292
+ <div class="cpub-sec-head"><h2><i class="fa-solid fa-users" style="color: var(--accent);"></i> Participants</h2><span class="cpub-sec-sub">{{ participants.length }}</span></div>
293
+ <div class="cpub-participant-grid">
294
+ <NuxtLink v-for="p in participants" :key="p.username" :to="`/u/${p.username}`" class="cpub-participant">
295
+ <span class="cpub-participant-av">
296
+ <img v-if="p.avatar" :src="p.avatar" :alt="p.name" />
297
+ <span v-else>{{ (p.name || p.username || '?').charAt(0).toUpperCase() }}</span>
298
+ </span>
299
+ <span class="cpub-participant-info">
300
+ <span class="cpub-participant-name">{{ p.name }}</span>
301
+ <span class="cpub-participant-meta">{{ p.count }} {{ p.count === 1 ? 'entry' : 'entries' }}</span>
302
+ </span>
303
+ </NuxtLink>
304
+ </div>
305
+ </div>
306
+
264
307
  <!-- JUDGES -->
265
308
  <div v-show="activeTab === 'judges'" id="cpub-panel-judges" role="tabpanel" aria-labelledby="cpub-tab-judges" tabindex="0">
266
309
  <ContestJudges :judges="judges" />
@@ -309,9 +352,25 @@ async function withdrawEntry(entryId: string): Promise<void> {
309
352
 
310
353
  [role="tabpanel"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
311
354
 
355
+ /* VISIBILITY BANNER */
356
+ .cpub-visibility-banner { display: flex; align-items: center; gap: 8px; padding: 10px 14px; margin-bottom: 16px; font-size: 12px; color: var(--text-dim); background: var(--surface2); border: var(--border-width-default) solid var(--border); }
357
+ .cpub-visibility-banner i { color: var(--accent); }
358
+
312
359
  /* SECTION HEADERS */
313
360
  .cpub-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
314
361
  .cpub-sec-head h2 { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
362
+ .cpub-sec-sub { font-size: 11px; color: var(--text-faint); margin-left: auto; font-family: var(--font-mono); }
363
+
364
+ /* PARTICIPANTS */
365
+ .cpub-participant-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
366
+ .cpub-participant { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); text-decoration: none; }
367
+ .cpub-participant:hover { box-shadow: var(--shadow-accent); }
368
+ .cpub-participant-av { width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; font-family: var(--font-mono); border: var(--border-width-default) solid var(--border); background: var(--surface3); color: var(--text-dim); overflow: hidden; }
369
+ .cpub-participant-av img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
370
+ .cpub-participant-info { display: flex; flex-direction: column; min-width: 0; }
371
+ .cpub-participant-name { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
372
+ .cpub-participant-meta { font-size: 10px; color: var(--text-faint); font-family: var(--font-mono); }
373
+ @media (max-width: 480px) { .cpub-participant-grid { grid-template-columns: 1fr; } }
315
374
 
316
375
  /* ABOUT */
317
376
  .cpub-about-section { margin-bottom: 20px; }
@@ -17,6 +17,16 @@ const judgingEndDate = ref('');
17
17
  const communityVotingEnabled = ref(false);
18
18
  const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
19
19
 
20
+ // Visibility & access
21
+ const visibility = ref<'public' | 'unlisted' | 'private'>('public');
22
+ const visibleToRoles = ref<string[]>([]);
23
+ const ROLE_OPTIONS = ['member', 'pro', 'verified', 'staff', 'admin'];
24
+ function toggleRole(r: string): void {
25
+ const i = visibleToRoles.value.indexOf(r);
26
+ if (i >= 0) visibleToRoles.value.splice(i, 1);
27
+ else visibleToRoles.value.push(r);
28
+ }
29
+
20
30
  // Entry rules
21
31
  const { enabledTypeMeta } = useContentTypes();
22
32
  const eligibleContentTypes = ref<string[]>([]); // empty = all types allowed
@@ -88,6 +98,8 @@ async function handleCreate(): Promise<void> {
88
98
  judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
89
99
  communityVotingEnabled: communityVotingEnabled.value,
90
100
  judgingVisibility: judgingVisibility.value,
101
+ visibility: visibility.value,
102
+ visibleToRoles: visibility.value === 'private' && visibleToRoles.value.length ? visibleToRoles.value : undefined,
91
103
  eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
92
104
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
93
105
  prizes: prizes.value
@@ -171,6 +183,30 @@ function prizeLabel(prize: Prize, idx: number): string {
171
183
  <p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
172
184
  </section>
173
185
 
186
+ <!-- Visibility & Access -->
187
+ <section class="cpub-form-section">
188
+ <h2 class="cpub-form-section-title">Visibility &amp; Access</h2>
189
+ <div class="cpub-form-field">
190
+ <label for="visibility" class="cpub-form-label">Who can see this contest</label>
191
+ <select id="visibility" v-model="visibility" class="cpub-form-input">
192
+ <option value="public">Public — listed and visible to everyone</option>
193
+ <option value="unlisted">Unlisted — visible by direct link, hidden from listings</option>
194
+ <option value="private">Private — restricted (you can publish it later)</option>
195
+ </select>
196
+ </div>
197
+ <div v-if="visibility === 'private'" class="cpub-form-field">
198
+ <span class="cpub-form-label">Also visible to roles</span>
199
+ <p class="cpub-form-hint">Owner, admins, judges, and reviewers (added after creation) can always see it. Optionally grant whole roles too.</p>
200
+ <div class="cpub-type-options" role="group" aria-label="Roles that can view">
201
+ <label v-for="r in ROLE_OPTIONS" :key="r" class="cpub-form-check">
202
+ <input type="checkbox" :checked="visibleToRoles.includes(r)" @change="toggleRole(r)" />
203
+ <span>{{ r }}</span>
204
+ </label>
205
+ </div>
206
+ </div>
207
+ <p v-if="visibility === 'private'" class="cpub-form-hint">Add named reviewers (stakeholders) from the contest's Edit page after creating it.</p>
208
+ </section>
209
+
174
210
  <!-- Entry Rules -->
175
211
  <section class="cpub-form-section">
176
212
  <h2 class="cpub-form-section-title">Entries</h2>
@@ -1,4 +1,4 @@
1
- import { listContestEntries, getContestBySlug, isContestJudge, shouldRevealScores } from '@commonpub/server';
1
+ import { listContestEntries, getContestBySlug, isContestJudge, shouldRevealScores, canViewContest } from '@commonpub/server';
2
2
  import type { ContestEntryItem } from '@commonpub/server';
3
3
  import { z } from 'zod';
4
4
 
@@ -21,6 +21,10 @@ export default defineEventHandler(async (event): Promise<{ items: ContestEntryIt
21
21
  // Aggregate score visibility additionally honours the contest's
22
22
  // judgingVisibility setting (public / judges-only / private).
23
23
  const user = getOptionalUser(event);
24
+ // Don't leak a private contest's entries to viewers who can't see the contest.
25
+ if (!(await canViewContest(db, contest, user))) {
26
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
27
+ }
24
28
  let privileged = false;
25
29
  if (user) {
26
30
  privileged =
@@ -1,4 +1,4 @@
1
- import { submitContestEntry, getContestBySlug } from '@commonpub/server';
1
+ import { submitContestEntry, getContestBySlug, canViewContest } from '@commonpub/server';
2
2
  import type { ContestEntryItem } from '@commonpub/server';
3
3
  import { z } from 'zod';
4
4
 
@@ -13,6 +13,10 @@ export default defineEventHandler(async (event): Promise<ContestEntryItem> => {
13
13
  const { slug } = parseParams(event, { slug: 'string' });
14
14
  const contest = await getContestBySlug(db, slug);
15
15
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
+ // Can't enter a contest you can't see.
17
+ if (!(await canViewContest(db, contest, user))) {
18
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
19
+ }
16
20
  const input = await parseBody(event, submitEntrySchema);
17
21
 
18
22
  const entry = await submitContestEntry(db, contest.id, input.contentId, user.id);
@@ -1,4 +1,4 @@
1
- import { getContestBySlug } from '@commonpub/server';
1
+ import { getContestBySlug, canViewContest } from '@commonpub/server';
2
2
  import type { ContestDetail } from '@commonpub/server';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<ContestDetail> => {
@@ -7,5 +7,11 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
7
7
  const { slug } = parseParams(event, { slug: 'string' });
8
8
  const contest = await getContestBySlug(db, slug);
9
9
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
10
+ // Access control: private contests are only visible to owner/admin/stakeholders/
11
+ // judges/allowed-roles. 404 (not 403) so we don't leak that the contest exists.
12
+ const user = getOptionalUser(event);
13
+ if (!(await canViewContest(db, contest, user))) {
14
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
+ }
10
16
  return contest;
11
17
  });
@@ -1,4 +1,4 @@
1
- import { getContestBySlug, listContestJudges } from '@commonpub/server';
1
+ import { getContestBySlug, listContestJudges, canViewContest } from '@commonpub/server';
2
2
 
3
3
  /**
4
4
  * GET /api/contests/:slug/judges
@@ -12,6 +12,9 @@ export default defineEventHandler(async (event) => {
12
12
 
13
13
  const contest = await getContestBySlug(db, slug);
14
14
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
+ if (!(await canViewContest(db, contest, getOptionalUser(event)))) {
16
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
17
+ }
15
18
 
16
19
  return listContestJudges(db, contest.id);
17
20
  });
@@ -0,0 +1,24 @@
1
+ import { getContestBySlug, removeContestStakeholder } from '@commonpub/server';
2
+
3
+ /**
4
+ * DELETE /api/contests/:slug/stakeholders/:userId
5
+ * Revoke a stakeholder's review access (contest owner or admin only).
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireFeature('contests');
9
+ const user = requireAuth(event);
10
+ const db = useDB();
11
+ const slug = getRouterParam(event, 'slug');
12
+ const userId = getRouterParam(event, 'userId');
13
+ if (!slug || !userId) throw createError({ statusCode: 400, statusMessage: 'Missing slug or userId' });
14
+
15
+ const contest = await getContestBySlug(db, slug);
16
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
17
+ if (contest.createdById !== user.id && user.role !== 'admin') {
18
+ throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can manage stakeholders' });
19
+ }
20
+
21
+ const removed = await removeContestStakeholder(db, contest.id, userId);
22
+ if (!removed) throw createError({ statusCode: 404, statusMessage: 'Stakeholder not found' });
23
+ return { removed: true };
24
+ });
@@ -0,0 +1,21 @@
1
+ import { getContestBySlug, listContestStakeholders } from '@commonpub/server';
2
+
3
+ /**
4
+ * GET /api/contests/:slug/stakeholders
5
+ * List view-only stakeholders (contest owner or admin only).
6
+ */
7
+ export default defineEventHandler(async (event) => {
8
+ requireFeature('contests');
9
+ const user = requireAuth(event);
10
+ const db = useDB();
11
+ const slug = getRouterParam(event, 'slug');
12
+ if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
13
+
14
+ const contest = await getContestBySlug(db, slug);
15
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
+ if (contest.createdById !== user.id && user.role !== 'admin') {
17
+ throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can view stakeholders' });
18
+ }
19
+
20
+ return listContestStakeholders(db, contest.id);
21
+ });
@@ -0,0 +1,33 @@
1
+ import { getContestBySlug, addContestStakeholder } from '@commonpub/server';
2
+ import { z } from 'zod';
3
+
4
+ const addStakeholderSchema = z.object({ userId: z.string().uuid() });
5
+
6
+ /**
7
+ * POST /api/contests/:slug/stakeholders
8
+ * Grant a user view-only review access (contest owner or admin only).
9
+ */
10
+ export default defineEventHandler(async (event) => {
11
+ requireFeature('contests');
12
+ const user = requireAuth(event);
13
+ const db = useDB();
14
+ const slug = getRouterParam(event, 'slug');
15
+ if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
16
+
17
+ const contest = await getContestBySlug(db, slug);
18
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
19
+ if (contest.createdById !== user.id && user.role !== 'admin') {
20
+ throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can manage stakeholders' });
21
+ }
22
+
23
+ const body = await parseBody(event, addStakeholderSchema);
24
+ const result = await addContestStakeholder(db, contest.id, body.userId, {
25
+ contestSlug: slug,
26
+ contestTitle: contest.title,
27
+ invitedBy: user.id,
28
+ });
29
+ if (!result.added) {
30
+ throw createError({ statusCode: 400, statusMessage: result.error ?? 'Failed to add stakeholder' });
31
+ }
32
+ return { added: true };
33
+ });
@@ -1,4 +1,4 @@
1
- import { getContestBySlug, getContestEntryVotes } from '@commonpub/server';
1
+ import { getContestBySlug, getContestEntryVotes, canViewContest } from '@commonpub/server';
2
2
  import type { ContestEntryVoteInfo } from '@commonpub/server';
3
3
 
4
4
  /**
@@ -13,6 +13,9 @@ export default defineEventHandler(async (event): Promise<ContestEntryVoteInfo[]>
13
13
 
14
14
  const contest = await getContestBySlug(db, slug);
15
15
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
+ if (!(await canViewContest(db, contest, user))) {
17
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
18
+ }
16
19
  // Voting disabled, or contest not yet open (no entries) → empty array, not an error.
17
20
  if (!contest.communityVotingEnabled || contest.status === 'upcoming') return [];
18
21
 
@@ -6,5 +6,8 @@ export default defineEventHandler(async (event): Promise<PaginatedResponse<Conte
6
6
  requireFeature('contests');
7
7
  const db = useDB();
8
8
  const filters = parseQueryParams(event, contestFiltersSchema);
9
- return listContests(db, filters);
9
+ // Pass the viewer so the list can include their own drafts/hidden contests
10
+ // (and everything for admins) while keeping the public list public-only.
11
+ const user = getOptionalUser(event);
12
+ return listContests(db, filters, user ? { userId: user.id, role: user.role } : null);
10
13
  });