@commonpub/layer 0.81.0 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/AppToast.vue +1 -1
- package/components/ContentAvatar.vue +98 -0
- package/components/CpubCriteriaBar.vue +88 -0
- package/components/CpubDateTimeField.vue +73 -0
- package/components/CpubMarkdown.vue +3 -1
- package/components/FormatToggle.vue +2 -2
- package/components/ImageUpload.vue +5 -8
- package/components/MirrorDetailModal.vue +3 -1
- package/components/MirrorRequestApproveModal.vue +3 -1
- package/components/ProductEditModal.vue +184 -0
- package/components/RemoteFollowDialog.vue +2 -2
- package/components/SearchSidebar.vue +14 -21
- package/components/ShareToHubModal.vue +3 -1
- package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
- package/components/blocks/BlockCompareColumnsView.vue +92 -0
- package/components/blocks/BlockContentRenderer.vue +17 -0
- package/components/blocks/BlockCriteriaBarView.vue +25 -0
- package/components/blocks/BlockGalleryView.vue +5 -0
- package/components/blocks/BlockHtmlView.vue +26 -0
- package/components/blocks/BlockImageView.vue +4 -0
- package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
- package/components/blocks/BlockRoadmapView.vue +84 -0
- package/components/blocks/BlockSponsorsView.vue +89 -0
- package/components/blocks/BlockTableView.vue +49 -0
- package/components/blocks/BlockTabsView.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +155 -0
- package/components/contest/ContestCriteriaEditor.vue +79 -0
- package/components/contest/ContestEditor.vue +948 -0
- package/components/contest/ContestEntries.vue +1 -1
- package/components/contest/ContestEntryPrivateData.vue +126 -0
- package/components/contest/ContestHero.vue +114 -186
- package/components/contest/ContestJudgeManager.vue +6 -4
- package/components/contest/ContestJudgingCriteria.vue +5 -21
- package/components/contest/ContestPrizes.vue +8 -1
- package/components/contest/ContestProposalForm.vue +88 -0
- package/components/contest/ContestRules.vue +8 -1
- package/components/contest/ContestSidebar.vue +11 -3
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +54 -20
- package/components/contest/ContestSubmissionField.vue +141 -0
- package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
- package/components/contest/blocks/ContestTabPanel.vue +27 -0
- package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
- package/components/contest/blocks/HtmlBlock.vue +61 -0
- package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
- package/components/contest/blocks/RoadmapBlock.vue +127 -0
- package/components/contest/blocks/SponsorsBlock.vue +127 -0
- package/components/contest/blocks/TableBlock.vue +101 -0
- package/components/contest/blocks/TabsBlock.vue +168 -0
- package/components/editors/ArticleEditor.vue +9 -16
- package/components/editors/ExplainerEditor.vue +8 -5
- package/components/editors/ProjectEditor.vue +13 -10
- package/components/homepage/CustomHtmlSection.vue +11 -2
- package/components/hub/HubProducts.vue +4 -2
- package/components/nav/NavDropdown.vue +1 -5
- package/components/nav/NavLink.vue +2 -0
- package/components/views/ArticleView.vue +3 -56
- package/components/views/ExplainerView.vue +4 -0
- package/components/views/ProjectView.vue +83 -245
- package/composables/useAuth.ts +13 -0
- package/composables/useCan.ts +23 -0
- package/composables/useContestEditor.ts +388 -0
- package/composables/useDocsPageTree.ts +154 -0
- package/composables/useDocsSiteSettings.ts +107 -0
- package/composables/useEditorAutosave.ts +131 -0
- package/composables/useEngagement.ts +13 -6
- package/composables/useFeatures.ts +9 -1
- package/composables/useFileUpload.ts +60 -0
- package/composables/useProfileContent.ts +84 -0
- package/composables/useSanitize.ts +38 -4
- package/composables/useScrollSpy.ts +87 -0
- package/layouts/admin.vue +43 -18
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +8 -8
- package/pages/[type]/index.vue +6 -1
- package/pages/admin/api-keys.vue +13 -3
- package/pages/admin/features.vue +2 -0
- package/pages/admin/federation.vue +1 -1
- package/pages/admin/layouts/[id].vue +30 -2
- package/pages/admin/roles.vue +286 -0
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +81 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -764
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +97 -8
- package/pages/contests/[slug]/judge.vue +49 -26
- package/pages/contests/create.vue +5 -466
- package/pages/contests/index.vue +7 -2
- package/pages/cookies.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
- package/pages/docs/[siteSlug]/edit.vue +93 -231
- package/pages/events/[slug]/edit.vue +20 -20
- package/pages/events/create.vue +18 -18
- package/pages/events/index.vue +7 -2
- package/pages/hubs/[slug]/index.vue +34 -9
- package/pages/hubs/[slug]/invites.vue +312 -0
- package/pages/hubs/[slug]/members.vue +128 -0
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +6 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
- package/pages/learn/index.vue +8 -1
- package/pages/messages/index.vue +1 -1
- package/pages/mirror/[id].vue +1 -1
- package/pages/products/[slug].vue +55 -2
- package/pages/products/index.vue +6 -1
- package/pages/settings/account.vue +8 -8
- package/pages/settings/profile.vue +23 -14
- package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
- package/pages/u/[username]/followers.vue +11 -3
- package/pages/u/[username]/following.vue +10 -8
- package/pages/u/[username]/index.vue +73 -7
- package/pages/videos/index.vue +13 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
- package/server/api/admin/api-keys/[id].delete.ts +2 -2
- package/server/api/admin/api-keys/index.get.ts +1 -0
- package/server/api/admin/api-keys/index.post.ts +1 -0
- package/server/api/admin/federation/refederate.post.ts +18 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
- package/server/api/admin/layouts/[id].delete.ts +1 -4
- package/server/api/admin/layouts/[id].get.ts +1 -4
- package/server/api/admin/layouts/[id].put.ts +1 -4
- package/server/api/admin/permissions.get.ts +14 -0
- package/server/api/admin/roles/[id]/index.delete.ts +25 -0
- package/server/api/admin/roles/[id]/index.put.ts +24 -0
- package/server/api/admin/roles/index.get.ts +10 -0
- package/server/api/admin/roles/index.post.ts +27 -0
- package/server/api/admin/users/[id]/role.put.ts +20 -1
- package/server/api/admin/users/[id]/roles.get.ts +10 -0
- package/server/api/admin/users/[id]/roles.put.ts +17 -0
- package/server/api/auth/federated/login.post.ts +12 -5
- package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
- package/server/api/content/[id]/build.get.ts +11 -0
- package/server/api/content/[id]/report.post.ts +2 -0
- package/server/api/content/[id]/versions.get.ts +15 -0
- package/server/api/contests/[slug]/advance.post.ts +10 -5
- package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
- package/server/api/contests/[slug]/export.get.ts +43 -0
- package/server/api/contests/[slug]/index.get.ts +10 -2
- package/server/api/contests/[slug]/index.put.ts +11 -2
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- package/server/api/contests/[slug]/stakeholders/index.post.ts +12 -3
- package/server/api/contests/[slug]/transition.post.ts +8 -3
- package/server/api/contests/[slug]/user-search.get.ts +30 -0
- package/server/api/contests/index.post.ts +1 -1
- package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
- package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
- package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
- package/server/api/docs/[siteSlug]/search.get.ts +7 -1
- package/server/api/events/[slug]/attendees.get.ts +10 -0
- package/server/api/events/[slug].get.ts +9 -0
- package/server/api/events/index.get.ts +8 -1
- package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
- package/server/api/federation/content/[id]/build.get.ts +10 -0
- package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
- package/server/api/hubs/[slug]/invites.get.ts +5 -3
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
- package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
- package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
- package/server/api/hubs/[slug]/requests.get.ts +20 -0
- package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
- package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
- package/server/api/me.get.ts +7 -0
- package/server/api/products/[id].delete.ts +22 -2
- package/server/api/registry/ping.post.ts +17 -3
- package/server/api/search/index.get.ts +5 -3
- package/server/api/social/bookmark.get.ts +1 -0
- package/server/api/social/bookmark.post.ts +1 -0
- package/server/api/social/bookmarks.get.ts +1 -0
- package/server/api/social/comments/[id].delete.ts +1 -0
- package/server/api/social/comments.get.ts +1 -0
- package/server/api/social/comments.post.ts +1 -0
- package/server/api/social/like.get.ts +1 -0
- package/server/api/social/like.post.ts +1 -0
- package/server/api/users/[username]/content.get.ts +15 -3
- package/server/api/users/[username]/follow.delete.ts +1 -0
- package/server/api/users/[username]/follow.post.ts +1 -0
- package/server/api/users/[username]/followers.get.ts +2 -1
- package/server/api/users/[username]/following.get.ts +2 -1
- package/server/middleware/content-ap.ts +8 -3
- package/server/middleware/csrf.ts +93 -0
- package/server/plugins/federation-hub-sync.ts +48 -17
- package/server/plugins/notification-email.ts +22 -3
- package/server/routes/hubs/[slug]/inbox.ts +13 -1
- package/server/routes/inbox.ts +14 -1
- package/server/routes/users/[username]/inbox.ts +13 -1
- package/server/utils/inbox.ts +7 -2
- package/server/utils/validate.ts +22 -0
- package/theme/base.css +5 -0
- package/theme/prose.css +20 -0
- package/theme/stoa-dark.css +4 -0
- package/types/contestBlocks.ts +122 -0
- package/utils/contestBlocks.ts +107 -0
- package/utils/contestBody.ts +25 -0
- package/utils/contestStages.ts +62 -0
- package/utils/contestSubmission.ts +97 -0
- package/utils/datetime.ts +45 -0
- package/utils/projectBlocks.ts +162 -0
- package/components/editors/BlogEditor.vue +0 -648
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getContestBySlug, canViewContest } from '@commonpub/server';
|
|
1
|
+
import { getContestBySlug, canViewContest, isContestEditor } from '@commonpub/server';
|
|
2
2
|
import type { ContestDetail } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<ContestDetail> => {
|
|
@@ -13,5 +13,13 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
|
|
|
13
13
|
if (!(await canViewContest(db, contest, user))) {
|
|
14
14
|
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
// Per-request manage flag for the client (owner / editor / contest.manage).
|
|
17
|
+
// Server stays the enforcement boundary; this only drives UI affordances.
|
|
18
|
+
// Returned as a fresh object (not a mutation of the fetched row) so the
|
|
19
|
+
// per-viewer flag can never leak across requests if getContestBySlug is cached.
|
|
20
|
+
const viewerCanManage = user
|
|
21
|
+
? ownerOrPermission(event, contest.createdById, 'contest.manage') ||
|
|
22
|
+
(await isContestEditor(db, contest.id, user.id))
|
|
23
|
+
: false;
|
|
24
|
+
return { ...contest, viewerCanManage };
|
|
17
25
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { updateContest } from '@commonpub/server';
|
|
1
|
+
import { updateContest, getContestBySlug, isContestEditor } from '@commonpub/server';
|
|
2
2
|
import type { ContestDetail } from '@commonpub/server';
|
|
3
3
|
import { updateContestSchema } from '@commonpub/schema';
|
|
4
4
|
|
|
@@ -9,9 +9,18 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
|
|
|
9
9
|
const { slug } = parseParams(event, { slug: 'string' });
|
|
10
10
|
const input = await parseBody(event, updateContestSchema);
|
|
11
11
|
|
|
12
|
+
// Owner, a per-contest `editor` stakeholder, or a `contest.manage` holder may
|
|
13
|
+
// edit. The owner check inside updateContest covers the owner; pass canManage
|
|
14
|
+
// for the permission/editor paths (editor is also re-checked server-side).
|
|
15
|
+
const contest = await getContestBySlug(db, slug);
|
|
16
|
+
const canManage = contest
|
|
17
|
+
? ownerOrPermission(event, contest.createdById, 'contest.manage') ||
|
|
18
|
+
(await isContestEditor(db, contest.id, user.id))
|
|
19
|
+
: false;
|
|
20
|
+
|
|
12
21
|
let result;
|
|
13
22
|
try {
|
|
14
|
-
result = await updateContest(db, slug, user.id, input);
|
|
23
|
+
result = await updateContest(db, slug, user.id, input, canManage);
|
|
15
24
|
} catch (err) {
|
|
16
25
|
if (err instanceof Error && err.message === 'SLUG_TAKEN') {
|
|
17
26
|
throw createError({ statusCode: 409, statusMessage: 'That URL slug is already in use by another contest.' });
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import { judgeContestEntry } from '@commonpub/server';
|
|
1
|
+
import { getContestBySlug, judgeContestEntry } from '@commonpub/server';
|
|
2
2
|
import { judgeEntrySchema } from '@commonpub/schema';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
|
|
5
5
|
requireFeature('contests');
|
|
6
6
|
const user = requireAuth(event);
|
|
7
7
|
const db = useDB();
|
|
8
|
+
const { slug } = parseParams(event, { slug: 'string' });
|
|
8
9
|
const input = await parseBody(event, judgeEntrySchema);
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
// B5a — resolve the contest from the route `:slug` and assert the entry belongs
|
|
12
|
+
// to it, so the slug in the path actually scopes the judged entry.
|
|
13
|
+
const contest = await getContestBySlug(db, slug);
|
|
14
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
15
|
+
|
|
16
|
+
const result = await judgeContestEntry(db, input.entryId, input.score, user.id, input.feedback, input.criteriaScores, contest.id);
|
|
11
17
|
if (!result.judged) {
|
|
12
18
|
throw createError({ statusCode: 403, statusMessage: result.error ?? 'Judging failed' });
|
|
13
19
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { submitContestProposal, getContestBySlug, canViewContest } from '@commonpub/server';
|
|
2
|
+
import { stageSubmissionSchema } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* POST /api/contests/:slug/proposal
|
|
6
|
+
* Form-first proposal entry (Phase 4). Validates the stage form, creates a DRAFT
|
|
7
|
+
* placeholder project, links a contest entry to it, and records agreement
|
|
8
|
+
* acceptances + PII separately. Gated by features.contestProposals; the server
|
|
9
|
+
* enforces that the target stage is the current, proposal-mode submission stage.
|
|
10
|
+
*/
|
|
11
|
+
export default defineEventHandler(async (event): Promise<{ entryId: string; projectSlug: string; contentType: string }> => {
|
|
12
|
+
requireFeature('contests');
|
|
13
|
+
requireFeature('contestProposals');
|
|
14
|
+
const user = requireAuth(event);
|
|
15
|
+
const db = useDB();
|
|
16
|
+
const { slug } = parseParams(event, { slug: 'string' });
|
|
17
|
+
|
|
18
|
+
const contest = await getContestBySlug(db, slug);
|
|
19
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
20
|
+
if (!(await canViewContest(db, contest, user))) {
|
|
21
|
+
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const input = await parseBody(event, stageSubmissionSchema);
|
|
25
|
+
const result = await submitContestProposal(db, {
|
|
26
|
+
contestId: contest.id,
|
|
27
|
+
stageId: input.stageId,
|
|
28
|
+
fields: input.fields,
|
|
29
|
+
userId: user.id,
|
|
30
|
+
ip: getRequestIP(event) ?? null,
|
|
31
|
+
});
|
|
32
|
+
if (!result.ok) {
|
|
33
|
+
throw createError({ statusCode: 400, statusMessage: result.error });
|
|
34
|
+
}
|
|
35
|
+
return { entryId: result.entryId, projectSlug: result.projectSlug, contentType: result.contentType };
|
|
36
|
+
});
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { getContestBySlug, addContestStakeholder } from '@commonpub/server';
|
|
2
|
+
import { stakeholderRoleSchema } from '@commonpub/schema';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
|
|
4
|
-
const addStakeholderSchema = z.object({
|
|
5
|
+
const addStakeholderSchema = z.object({
|
|
6
|
+
userId: z.string().uuid(),
|
|
7
|
+
// 'reviewer' (view-only, default) or 'editor' (full edit rights to THIS
|
|
8
|
+
// contest only). Only owner / contest.manage can add or promote, so an editor
|
|
9
|
+
// cannot mint more editors.
|
|
10
|
+
role: stakeholderRoleSchema.optional(),
|
|
11
|
+
});
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
14
|
* POST /api/contests/:slug/stakeholders
|
|
8
|
-
* Grant a user
|
|
15
|
+
* Grant a user per-contest access (reviewer = view-only, editor = full edit of
|
|
16
|
+
* this contest). Contest owner or a `contest.manage` holder only.
|
|
9
17
|
*/
|
|
10
18
|
export default defineEventHandler(async (event) => {
|
|
11
19
|
requireFeature('contests');
|
|
@@ -22,6 +30,7 @@ export default defineEventHandler(async (event) => {
|
|
|
22
30
|
|
|
23
31
|
const body = await parseBody(event, addStakeholderSchema);
|
|
24
32
|
const result = await addContestStakeholder(db, contest.id, body.userId, {
|
|
33
|
+
role: body.role,
|
|
25
34
|
contestSlug: slug,
|
|
26
35
|
contestTitle: contest.title,
|
|
27
36
|
invitedBy: user.id,
|
|
@@ -29,5 +38,5 @@ export default defineEventHandler(async (event) => {
|
|
|
29
38
|
if (!result.added) {
|
|
30
39
|
throw createError({ statusCode: 400, statusMessage: result.error ?? 'Failed to add stakeholder' });
|
|
31
40
|
}
|
|
32
|
-
return { added: true };
|
|
41
|
+
return { added: true, updated: result.updated ?? false };
|
|
33
42
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getContestBySlug, transitionContestStatus } from '@commonpub/server';
|
|
1
|
+
import { getContestBySlug, transitionContestStatus, isContestEditor } from '@commonpub/server';
|
|
2
2
|
import type { ContestStatus } from '@commonpub/server';
|
|
3
3
|
import { contestTransitionSchema } from '@commonpub/schema';
|
|
4
4
|
|
|
@@ -14,10 +14,15 @@ export default defineEventHandler(async (event): Promise<{ transitioned: boolean
|
|
|
14
14
|
throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const canManage =
|
|
18
|
+
ownerOrPermission(event, contest.createdById, 'contest.manage') ||
|
|
19
|
+
(await isContestEditor(db, contest.id, user.id));
|
|
20
|
+
|
|
21
|
+
const result = await transitionContestStatus(db, contest.id, user.id, input.status, canManage);
|
|
18
22
|
|
|
19
23
|
if (!result.transitioned) {
|
|
20
|
-
|
|
24
|
+
const denied = /authoriz|owner/i.test(result.error ?? '');
|
|
25
|
+
throw createError({ statusCode: denied ? 403 : 400, statusMessage: result.error || 'Transition failed' });
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
return { transitioned: true, newStatus: input.status };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getContestBySlug, searchUsers } from '@commonpub/server';
|
|
3
|
+
import type { UserSearchResult } from '@commonpub/server';
|
|
4
|
+
|
|
5
|
+
const querySchema = z.object({
|
|
6
|
+
q: z.string().min(2).max(100),
|
|
7
|
+
limit: z.coerce.number().int().min(1).max(25).optional(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Scoped user lookup for contest invite pickers (judges/reviewers). Returns
|
|
12
|
+
* PUBLIC fields only and is gated to contest managers (owner / contest.manage),
|
|
13
|
+
* so non-admin organizers can search without the admin-only user list (the 403
|
|
14
|
+
* bug the judge/stakeholder managers used to hit).
|
|
15
|
+
*/
|
|
16
|
+
export default defineEventHandler(async (event): Promise<UserSearchResult[]> => {
|
|
17
|
+
requireFeature('contests');
|
|
18
|
+
requireAuth(event);
|
|
19
|
+
const db = useDB();
|
|
20
|
+
const { slug } = parseParams(event, { slug: 'string' });
|
|
21
|
+
|
|
22
|
+
const contest = await getContestBySlug(db, slug);
|
|
23
|
+
if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
|
|
24
|
+
if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
|
|
25
|
+
throw createError({ statusCode: 403, statusMessage: 'Not authorized to manage this contest' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { q, limit } = await parseQueryParams(event, querySchema);
|
|
29
|
+
return searchUsers(db, q, limit ?? 10);
|
|
30
|
+
});
|
|
@@ -30,6 +30,6 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
|
|
|
30
30
|
|
|
31
31
|
return createContest(db, { ...input, slug, createdBy: user.id } as CreateContestInput, {
|
|
32
32
|
userRole: user.role,
|
|
33
|
-
contestCreationPolicy: config.instance.contestCreation ?? '
|
|
33
|
+
contestCreationPolicy: config.instance.contestCreation ?? 'admin',
|
|
34
34
|
});
|
|
35
35
|
});
|
|
@@ -20,7 +20,12 @@ export default defineEventHandler(async (event) => {
|
|
|
20
20
|
|
|
21
21
|
if (!version) return [];
|
|
22
22
|
|
|
23
|
+
// Public viewers only see published pages in nav; the site owner/admin (the docs
|
|
24
|
+
// editor uses this route too) sees drafts too.
|
|
25
|
+
const viewer = getOptionalUser(event);
|
|
26
|
+
const canSeeDrafts = !!viewer && (viewer.role === 'admin' || viewer.id === result.site.ownerId);
|
|
27
|
+
|
|
23
28
|
// Return pages directly as nav items
|
|
24
|
-
const pages = await listDocsPages(db, version.id);
|
|
29
|
+
const pages = await listDocsPages(db, version.id, { publishedOnly: !canSeeDrafts });
|
|
25
30
|
return pages.map(p => ({ id: p.id, title: p.title, sidebarLabel: p.sidebarLabel, slug: p.slug, sortOrder: p.sortOrder, parentId: p.parentId }));
|
|
26
31
|
});
|
|
@@ -15,7 +15,11 @@ export default defineEventHandler(async (event) => {
|
|
|
15
15
|
|
|
16
16
|
if (!version) throw createError({ statusCode: 404, statusMessage: 'No version found' });
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// Public viewers only see published pages; the site owner/admin sees drafts too.
|
|
19
|
+
const viewer = getOptionalUser(event);
|
|
20
|
+
const canSeeDrafts = !!viewer && (viewer.role === 'admin' || viewer.id === result.site.ownerId);
|
|
21
|
+
|
|
22
|
+
const pages = await listDocsPages(db, version.id, { publishedOnly: !canSeeDrafts });
|
|
19
23
|
const page = pages.find((p) => p.slug === pageSlug);
|
|
20
24
|
if (!page) throw createError({ statusCode: 404, statusMessage: 'Page not found' });
|
|
21
25
|
|
|
@@ -30,7 +30,12 @@ export default defineEventHandler(async (event) => {
|
|
|
30
30
|
throw createError({ statusCode: 404, statusMessage: 'No version found for docs site' });
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// Public viewers only see published pages; the site owner/admin (e.g. the docs
|
|
34
|
+
// editor at /docs/[siteSlug]/edit, which hits this same route) sees drafts too.
|
|
35
|
+
const viewer = getOptionalUser(event);
|
|
36
|
+
const canSeeDrafts = !!viewer && (viewer.role === 'admin' || viewer.id === result.site.ownerId);
|
|
37
|
+
|
|
38
|
+
const pages = await listDocsPages(db, version.id, { publishedOnly: !canSeeDrafts });
|
|
34
39
|
|
|
35
40
|
// Content is JSONB — arrays come back parsed, legacy strings stay as strings
|
|
36
41
|
return pages.map((page) => {
|
|
@@ -17,5 +17,11 @@ export default defineEventHandler(async (event) => {
|
|
|
17
17
|
const version = result.versions?.find((v) => v.isDefault) ?? result.versions?.[0];
|
|
18
18
|
if (!version) return [];
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Public viewers only search published pages; the site owner/admin sees drafts too.
|
|
21
|
+
const viewer = getOptionalUser(event);
|
|
22
|
+
const canSeeDrafts = !!viewer && (viewer.role === 'admin' || viewer.id === result.site.ownerId);
|
|
23
|
+
|
|
24
|
+
return searchDocsPages(db, result.site.id, version.id, query.q ?? '', config.docs.searchLanguage, {
|
|
25
|
+
publishedOnly: !canSeeDrafts,
|
|
26
|
+
});
|
|
21
27
|
});
|
|
@@ -14,6 +14,16 @@ export default defineEventHandler(async (event) => {
|
|
|
14
14
|
const existing = await getEventBySlug(db, slug);
|
|
15
15
|
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
16
16
|
|
|
17
|
+
// Don't expose the attendee roster for non-published events (draft/cancelled/etc)
|
|
18
|
+
// to the public; gate to the creator/admin, matching the event-detail gating.
|
|
19
|
+
// (Fuller per-event roster privacy for published events is a separate product
|
|
20
|
+
// decision — tracked as a follow-up.)
|
|
21
|
+
if (existing.status !== 'published' && existing.status !== 'active') {
|
|
22
|
+
const viewer = getOptionalUser(event);
|
|
23
|
+
const canView = !!viewer && (viewer.role === 'admin' || viewer.id === existing.createdById);
|
|
24
|
+
if (!canView) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
const query = getQuery(event);
|
|
18
28
|
return listEventAttendees(db, existing.id, {
|
|
19
29
|
status: (query.status as AttendeeStatus) || undefined,
|
|
@@ -13,5 +13,14 @@ export default defineEventHandler(async (event) => {
|
|
|
13
13
|
const result = await getEventBySlug(db, slug);
|
|
14
14
|
if (!result) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
15
15
|
|
|
16
|
+
// Only published/active events are publicly viewable. Draft/cancelled/completed
|
|
17
|
+
// events are visible only to the creator or an admin (mirrors content/contest
|
|
18
|
+
// draft gating). 404 (not 403) so the slug's existence isn't leaked.
|
|
19
|
+
if (result.status !== 'published' && result.status !== 'active') {
|
|
20
|
+
const viewer = getOptionalUser(event);
|
|
21
|
+
const canView = !!viewer && (viewer.role === 'admin' || viewer.id === result.createdById);
|
|
22
|
+
if (!canView) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
return result;
|
|
17
26
|
});
|
|
@@ -23,9 +23,16 @@ export default defineEventHandler(async (event) => {
|
|
|
23
23
|
userId = user.id;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// hubId feeds a uuid SQL bind; reject a malformed value at the door (a
|
|
27
|
+
// non-uuid string reaching the bind throws an unhandled 500).
|
|
28
|
+
const hubId = (query.hubId as string) || undefined;
|
|
29
|
+
if (hubId && !isUuid(hubId)) {
|
|
30
|
+
throw createError({ statusCode: 400, statusMessage: 'Invalid hubId' });
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
return listEvents(db, {
|
|
27
34
|
status,
|
|
28
|
-
hubId
|
|
35
|
+
hubId,
|
|
29
36
|
upcoming: query.upcoming === 'true',
|
|
30
37
|
featured: query.featured === 'true',
|
|
31
38
|
userId,
|
|
@@ -2,7 +2,7 @@ import { listFederatedHubPostReplies } from '@commonpub/server';
|
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
requireFeature('federation');
|
|
5
|
-
const postId =
|
|
5
|
+
const { postId } = parseParams(event, { id: 'uuid', postId: 'uuid' });
|
|
6
6
|
const query = getQuery(event);
|
|
7
7
|
const db = useDB();
|
|
8
8
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isFederatedBuildMarked } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
// Hydration counterpart to the federated build toggle — see content/[id]/build.get.ts.
|
|
4
|
+
export default defineEventHandler(async (event): Promise<{ marked: boolean }> => {
|
|
5
|
+
requireFeature('federation');
|
|
6
|
+
const user = requireAuth(event);
|
|
7
|
+
const db = useDB();
|
|
8
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
9
|
+
return { marked: await isFederatedBuildMarked(db, id, user.id) };
|
|
10
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { revokeInvite, getHubBySlug } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event): Promise<{ revoked: boolean }> => {
|
|
4
|
+
const user = requireAuth(event);
|
|
5
|
+
const db = useDB();
|
|
6
|
+
const { slug, id } = parseParams(event, { slug: 'string', id: 'uuid' });
|
|
7
|
+
const hub = await getHubBySlug(db, slug);
|
|
8
|
+
if (!hub) {
|
|
9
|
+
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const revoked = await revokeInvite(db, id, user.id, hub.id);
|
|
13
|
+
if (!revoked) {
|
|
14
|
+
throw createError({ statusCode: 403, statusMessage: 'Not authorized to revoke invites' });
|
|
15
|
+
}
|
|
16
|
+
return { revoked };
|
|
17
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listInvites, getHubBySlug, getMember } from '@commonpub/server';
|
|
1
|
+
import { listInvites, getHubBySlug, getMember, hasPermission } from '@commonpub/server';
|
|
2
2
|
import type { HubInviteItem } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<HubInviteItem[]> => {
|
|
@@ -10,9 +10,11 @@ export default defineEventHandler(async (event): Promise<HubInviteItem[]> => {
|
|
|
10
10
|
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
//
|
|
13
|
+
// Invite management is admin+ (matches createInvite/revokeInvite's manageMembers).
|
|
14
|
+
// Gating the list at the same level keeps moderators from seeing write controls
|
|
15
|
+
// they can't use.
|
|
14
16
|
const member = await getMember(db, hub.id, user.id);
|
|
15
|
-
if (!member || !
|
|
17
|
+
if (!member || !hasPermission(member.role, 'manageMembers')) {
|
|
16
18
|
throw createError({ statusCode: 403, statusMessage: 'Insufficient permissions' });
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -7,8 +7,7 @@ import { getPollOptions, getUserPollVote } from '@commonpub/server';
|
|
|
7
7
|
export default defineEventHandler(async (event) => {
|
|
8
8
|
requireFeature('hubs');
|
|
9
9
|
const db = useDB();
|
|
10
|
-
const postId =
|
|
11
|
-
if (!postId) throw createError({ statusCode: 400, statusMessage: 'Missing postId' });
|
|
10
|
+
const { postId } = parseParams(event, { postId: 'uuid' });
|
|
12
11
|
|
|
13
12
|
const options = await getPollOptions(db, postId);
|
|
14
13
|
const user = getOptionalUser(event);
|
|
@@ -13,8 +13,7 @@ export default defineEventHandler(async (event) => {
|
|
|
13
13
|
requireFeature('hubs');
|
|
14
14
|
const user = requireAuth(event);
|
|
15
15
|
const db = useDB();
|
|
16
|
-
const postId =
|
|
17
|
-
if (!postId) throw createError({ statusCode: 400, statusMessage: 'Missing postId' });
|
|
16
|
+
const { postId } = parseParams(event, { postId: 'uuid' });
|
|
18
17
|
|
|
19
18
|
const body = await parseBody(event, pollVoteSchema);
|
|
20
19
|
const result = await voteOnPoll(db, postId, body.optionId, user.id);
|
|
@@ -13,8 +13,7 @@ export default defineEventHandler(async (event) => {
|
|
|
13
13
|
requireFeature('hubs');
|
|
14
14
|
const user = requireAuth(event);
|
|
15
15
|
const db = useDB();
|
|
16
|
-
const postId =
|
|
17
|
-
if (!postId) throw createError({ statusCode: 400, statusMessage: 'Missing postId' });
|
|
16
|
+
const { postId } = parseParams(event, { postId: 'uuid' });
|
|
18
17
|
|
|
19
18
|
const body = await parseBody(event, voteSchema);
|
|
20
19
|
return voteOnPost(db, postId, user.id, body.direction);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { approveJoinRequest, getHubBySlug } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event): Promise<{ approved: boolean; error?: string }> => {
|
|
4
|
+
const user = requireAuth(event);
|
|
5
|
+
const db = useDB();
|
|
6
|
+
const { slug, userId } = parseParams(event, { slug: 'string', userId: 'uuid' });
|
|
7
|
+
|
|
8
|
+
const hub = await getHubBySlug(db, slug);
|
|
9
|
+
if (!hub) {
|
|
10
|
+
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Permission (manageMembers) is enforced inside approveJoinRequest.
|
|
14
|
+
return approveJoinRequest(db, user.id, hub.id, userId);
|
|
15
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { denyJoinRequest, getHubBySlug } from '@commonpub/server';
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event): Promise<{ denied: boolean; error?: string }> => {
|
|
4
|
+
const user = requireAuth(event);
|
|
5
|
+
const db = useDB();
|
|
6
|
+
const { slug, userId } = parseParams(event, { slug: 'string', userId: 'uuid' });
|
|
7
|
+
|
|
8
|
+
const hub = await getHubBySlug(db, slug);
|
|
9
|
+
if (!hub) {
|
|
10
|
+
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Permission (manageMembers) is enforced inside denyJoinRequest.
|
|
14
|
+
return denyJoinRequest(db, user.id, hub.id, userId);
|
|
15
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { listJoinRequests, getHubBySlug, getMember } from '@commonpub/server';
|
|
2
|
+
import type { HubMemberItem } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event): Promise<{ items: HubMemberItem[]; total: number }> => {
|
|
5
|
+
const user = requireAuth(event);
|
|
6
|
+
const db = useDB();
|
|
7
|
+
const { slug } = parseParams(event, { slug: 'string' });
|
|
8
|
+
const hub = await getHubBySlug(db, slug);
|
|
9
|
+
if (!hub) {
|
|
10
|
+
throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Reviewing join requests is a member-management action (admin/owner).
|
|
14
|
+
const member = await getMember(db, hub.id, user.id);
|
|
15
|
+
if (!member || !['admin', 'owner'].includes(member.role)) {
|
|
16
|
+
throw createError({ statusCode: 403, statusMessage: 'Insufficient permissions' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return listJoinRequests(db, hub.id);
|
|
20
|
+
});
|
|
@@ -3,8 +3,7 @@ import { deleteHubResource } from '@commonpub/server';
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
const db = useDB();
|
|
5
5
|
const user = requireAuth(event);
|
|
6
|
-
const id =
|
|
7
|
-
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing resource ID' });
|
|
6
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
8
7
|
|
|
9
8
|
const result = await deleteHubResource(db, id, user.id);
|
|
10
9
|
if (!result.success) {
|
|
@@ -4,8 +4,7 @@ import { updateHubResourceSchema } from '@commonpub/schema';
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
5
|
const db = useDB();
|
|
6
6
|
const user = requireAuth(event);
|
|
7
|
-
const id =
|
|
8
|
-
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing resource ID' });
|
|
7
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
9
8
|
|
|
10
9
|
const input = await parseBody(event, updateHubResourceSchema);
|
|
11
10
|
|
package/server/api/me.get.ts
CHANGED
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export default defineEventHandler((event) => {
|
|
8
8
|
const { user, session } = event.context.auth ?? {};
|
|
9
|
+
// Effective permissions resolved by the auth middleware (RBAC). The admin
|
|
10
|
+
// floor lives in users.role, so the set is empty for admins — useCan() applies
|
|
11
|
+
// the floor client-side. Permissions/roleKeys are advisory (UX only); the
|
|
12
|
+
// server is always the enforcement boundary.
|
|
13
|
+
const resolved = event.context.cpubPermissions;
|
|
9
14
|
return {
|
|
10
15
|
user: user ?? null,
|
|
11
16
|
session: session ?? null,
|
|
17
|
+
permissions: resolved ? [...resolved.permissions] : [],
|
|
18
|
+
roleKeys: resolved?.roleKeys ?? [],
|
|
12
19
|
};
|
|
13
20
|
});
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import { deleteProduct } from '@commonpub/server';
|
|
2
|
+
import { products } from '@commonpub/schema';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
2
4
|
|
|
3
5
|
export default defineEventHandler(async (event): Promise<{ deleted: boolean }> => {
|
|
6
|
+
const user = requireAuth(event);
|
|
4
7
|
const db = useDB();
|
|
5
|
-
requirePermission(event, 'content.moderate');
|
|
6
8
|
const { id } = parseParams(event, { id: 'uuid' });
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
// Owner OR moderator may delete. Resolve the product's owner first.
|
|
11
|
+
const [product] = await db
|
|
12
|
+
.select({ createdById: products.createdById })
|
|
13
|
+
.from(products)
|
|
14
|
+
.where(eq(products.id, id))
|
|
15
|
+
.limit(1);
|
|
16
|
+
|
|
17
|
+
if (!product) {
|
|
18
|
+
throw createError({ statusCode: 404, statusMessage: 'Product not found' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!ownerOrPermission(event, product.createdById, 'content.moderate')) {
|
|
22
|
+
throw createError({ statusCode: 403, statusMessage: 'Missing permission: content.moderate' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Pass userId for an owner-scoped data-layer delete; moderators (non-owners)
|
|
26
|
+
// are already gated above, so they delete unconditionally.
|
|
27
|
+
const isOwner = product.createdById === user.id;
|
|
28
|
+
const deleted = await deleteProduct(db, id, isOwner ? user.id : undefined);
|
|
9
29
|
|
|
10
30
|
if (!deleted) {
|
|
11
31
|
throw createError({ statusCode: 404, statusMessage: 'Product not found' });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { recordRegistryPing, createRateLimitStore, getClientIp } from '@commonpub/server';
|
|
1
|
+
import { recordRegistryPing, createRateLimitStore, getClientIp, recordActivitySeen } from '@commonpub/server';
|
|
2
2
|
import { verifyInboxRequest, extractDomain } from '../../utils/inbox';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -32,13 +32,27 @@ export default defineEventHandler(async (event) => {
|
|
|
32
32
|
const preRl = await store.check(`registry:ping:ip:${getClientIp(event)}`, PRE_TIER);
|
|
33
33
|
if (!preRl.allowed) reject429(preRl.resetAt);
|
|
34
34
|
|
|
35
|
-
const { actorUri } = await verifyInboxRequest(event, 'registry-ping');
|
|
35
|
+
const { actorUri, body } = await verifyInboxRequest(event, 'registry-ping');
|
|
36
36
|
const domain = extractDomain(actorUri);
|
|
37
37
|
|
|
38
38
|
// Per-verified-domain cap (the signer is now cryptographically known).
|
|
39
39
|
const rl = await store.check(`registry:ping:${domain}`, PING_TIER);
|
|
40
40
|
if (!rl.allowed) reject429(rl.resetAt);
|
|
41
41
|
|
|
42
|
-
const
|
|
42
|
+
const db = useDB();
|
|
43
|
+
|
|
44
|
+
// Replay dedup: claim the verified activity id BEFORE recording the ping so a
|
|
45
|
+
// replayed, validly-signed ping can't re-trigger the NodeInfo pull. No id =
|
|
46
|
+
// process normally. Placed after verification so attacker-chosen ids can't be
|
|
47
|
+
// seeded.
|
|
48
|
+
const activityId = body.id;
|
|
49
|
+
if (typeof activityId === 'string' && activityId.length > 0) {
|
|
50
|
+
const first = await recordActivitySeen(db, activityId);
|
|
51
|
+
if (!first) {
|
|
52
|
+
return { status: 'ok' };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const result = await recordRegistryPing(db, domain, actorUri);
|
|
43
57
|
return { status: result };
|
|
44
58
|
});
|
|
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
const searchQuerySchema = z.object({
|
|
8
8
|
q: z.string().max(200).optional(),
|
|
9
9
|
type: z.string().optional(),
|
|
10
|
-
sort: z.enum(['relevance', 'recent', 'popular']).optional(),
|
|
10
|
+
sort: z.enum(['relevance', 'recent', 'popular', 'likes']).optional(),
|
|
11
11
|
difficulty: z.string().optional(),
|
|
12
12
|
tags: z.string().optional(),
|
|
13
13
|
author: z.string().optional(),
|
|
@@ -141,8 +141,10 @@ export default defineEventHandler(async (event): Promise<{ items: unknown[]; tot
|
|
|
141
141
|
difficulty: params.difficulty as ContentFilters['difficulty'],
|
|
142
142
|
tag: tagList[0],
|
|
143
143
|
// Postgres has no relevance ranking (that's Meilisearch's job) — the old
|
|
144
|
-
// path also fell back to recency for 'relevance'.
|
|
145
|
-
|
|
144
|
+
// path also fell back to recency for 'relevance'. 'popular'/'likes' pass
|
|
145
|
+
// through (listContent coerces them to recency when the merge federates,
|
|
146
|
+
// since federated rows carry no view/like counts — same as 'popular').
|
|
147
|
+
sort: params.sort === 'popular' || params.sort === 'likes' ? params.sort : 'recent',
|
|
146
148
|
limit,
|
|
147
149
|
offset,
|
|
148
150
|
};
|
|
@@ -7,6 +7,7 @@ const checkSchema = z.object({
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
export default defineEventHandler(async (event): Promise<{ bookmarked: boolean }> => {
|
|
10
|
+
requireFeature('social');
|
|
10
11
|
const user = requireAuth(event);
|
|
11
12
|
const db = useDB();
|
|
12
13
|
const query = parseQueryParams(event, checkSchema);
|
|
@@ -7,6 +7,7 @@ const toggleBookmarkSchema = z.object({
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
export default defineEventHandler(async (event): Promise<{ bookmarked: boolean }> => {
|
|
10
|
+
requireFeature('social');
|
|
10
11
|
const user = requireAuth(event);
|
|
11
12
|
const db = useDB();
|
|
12
13
|
const input = await parseBody(event, toggleBookmarkSchema);
|
|
@@ -8,6 +8,7 @@ const bookmarksQuerySchema = z.object({
|
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
export default defineEventHandler(async (event): Promise<PaginatedResponse<BookmarkItem>> => {
|
|
11
|
+
requireFeature('social');
|
|
11
12
|
const user = requireAuth(event);
|
|
12
13
|
const db = useDB();
|
|
13
14
|
const query = parseQueryParams(event, bookmarksQuerySchema);
|