diff --git a/core/actions/_lib/runActionInstance.ts b/core/actions/_lib/runActionInstance.ts index 4ea2a81d3..b4a6ea0b9 100644 --- a/core/actions/_lib/runActionInstance.ts +++ b/core/actions/_lib/runActionInstance.ts @@ -38,8 +38,14 @@ const _runActionInstance = async ( args: RunActionInstanceArgs & { actionRunId: ActionRunsId }, trx = db ): Promise => { + const isActionUserInitiated = "userId" in args; + const pubPromise = getPubsWithRelatedValuesAndChildren( - { pubId: args.pubId, communityId: args.communityId }, + { + pubId: args.pubId, + communityId: args.communityId, + userId: isActionUserInitiated ? args.userId : undefined, + }, { withPubType: true, withStage: true, diff --git a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts index da95772c9..a5ef55d79 100644 --- a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts +++ b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts @@ -1,5 +1,8 @@ +import type { User } from "lucia"; + import { headers } from "next/headers"; import { createNextHandler } from "@ts-rest/serverless/next"; +import { jsonObjectFrom } from "kysely/helpers/postgres"; import { z } from "zod"; import type { Communities, CommunitiesId, PubsId, PubTypesId, StagesId, UsersId } from "db/public"; @@ -39,7 +42,7 @@ import { getCommunitySlug } from "~/lib/server/cache/getCommunitySlug"; import { findCommunityBySlug } from "~/lib/server/community"; import { getPubType, getPubTypesForCommunity } from "~/lib/server/pubtype"; import { getStages } from "~/lib/server/stages"; -import { getSuggestedUsers } from "~/lib/server/user"; +import { getSuggestedUsers, SAFE_USER_SELECT } from "~/lib/server/user"; const baseAuthorizationObject = Object.fromEntries( Object.keys(ApiAccessScope).map( @@ -77,13 +80,33 @@ const getAuthorization = async () => { const validatedAccessToken = await validateApiAccessToken(apiKey, community.id); - const rules = (await db + const rules = await db .selectFrom("api_access_permissions") - .selectAll() + .selectAll("api_access_permissions") + .innerJoin( + "api_access_tokens", + "api_access_tokens.id", + "api_access_permissions.apiAccessTokenId" + ) + .select((eb) => + jsonObjectFrom( + eb + .selectFrom("users") + .select(SAFE_USER_SELECT) + .whereRef("users.id", "=", eb.ref("api_access_tokens.issuedById")) + ).as("user") + ) .where("api_access_permissions.apiAccessTokenId", "=", validatedAccessToken.id) - .execute()) as ApiAccessPermission[]; + .$castTo() + .execute(); + + const user = rules[0].user; + if (!rules[0].user) { + throw new NotFoundError(`Unable to locate user associated with api token`); + } return { + user, authorization: rules.reduce((acc, curr) => { const { scope, constraints, accessType } = curr; if (!constraints) { @@ -103,6 +126,7 @@ type AuthorizationOutput = { authorization: true | Exclude<(typeof baseAuthorizationObject)[S][AT], false>; community: Communities; lastModifiedBy: LastModifiedBy; + user: User; }; const checkAuthorization = async < @@ -127,7 +151,7 @@ const checkAuthorization = async < const authorizationTokenWithBearer = headers().get("Authorization"); if (authorizationTokenWithBearer) { - const { authorization, community, apiAccessTokenId } = await getAuthorization(); + const { user, authorization, community, apiAccessTokenId } = await getAuthorization(); const constraints = authorization[token.scope][token.type]; if (!constraints) { @@ -142,6 +166,7 @@ const checkAuthorization = async < authorization: constraints as Exclude, community, lastModifiedBy, + user, }; } @@ -171,7 +196,7 @@ const checkAuthorization = async < // Handle cases where we only want to check for login but have no specific capability yet if (typeof cookies === "boolean") { - return { authorization: true as const, community, lastModifiedBy }; + return { user, authorization: true as const, community, lastModifiedBy }; } const can = await userCan(cookies.capability, cookies.target, user.id); @@ -182,7 +207,7 @@ const checkAuthorization = async < ); } - return { authorization: true as const, community, lastModifiedBy }; + return { user, authorization: true as const, community, lastModifiedBy }; }; const shouldReturnRepresentation = () => { @@ -199,7 +224,7 @@ const handler = createNextHandler( { pubs: { get: async ({ params, query }) => { - const { community } = await checkAuthorization({ + const { user, community } = await checkAuthorization({ token: { scope: ApiAccessScope.pub, type: ApiAccessType.read }, cookies: { capability: Capabilities.viewPub, @@ -211,6 +236,7 @@ const handler = createNextHandler( { pubId: params.pubId as PubsId, communityId: community.id, + userId: user.id, }, query ); @@ -221,7 +247,7 @@ const handler = createNextHandler( }; }, getMany: async ({ query }) => { - const { community } = await checkAuthorization({ + const { user, community } = await checkAuthorization({ token: { scope: ApiAccessScope.pub, type: ApiAccessType.read }, // TODO: figure out capability here cookies: false, @@ -234,6 +260,7 @@ const handler = createNextHandler( communityId: community.id, pubTypeId, stageId, + userId: user.id, }, rest ); @@ -280,7 +307,7 @@ const handler = createNextHandler( }; }, update: async ({ params, body }) => { - const { community, lastModifiedBy } = await checkAuthorization({ + const { user, community, lastModifiedBy } = await checkAuthorization({ token: { scope: ApiAccessScope.pub, type: ApiAccessType.write }, cookies: { capability: Capabilities.updatePubValues, @@ -316,6 +343,7 @@ const handler = createNextHandler( const pub = await getPubsWithRelatedValuesAndChildren({ pubId: params.pubId as PubsId, communityId: community.id, + userId: user.id, }); return { @@ -350,7 +378,7 @@ const handler = createNextHandler( }, relations: { remove: async ({ params, body }) => { - const { community, lastModifiedBy } = await checkAuthorization({ + const { user, community, lastModifiedBy } = await checkAuthorization({ token: { scope: ApiAccessScope.pub, type: ApiAccessType.write }, cookies: { capability: Capabilities.deletePub, @@ -429,6 +457,7 @@ const handler = createNextHandler( const pub = await getPubsWithRelatedValuesAndChildren({ pubId: params.pubId as PubsId, communityId: community.id, + userId: user.id, }); return { @@ -437,7 +466,7 @@ const handler = createNextHandler( }; }, update: async ({ params, body }) => { - const { community, lastModifiedBy } = await checkAuthorization({ + const { user, community, lastModifiedBy } = await checkAuthorization({ token: { scope: ApiAccessScope.pub, type: ApiAccessType.write }, cookies: { capability: Capabilities.deletePub, @@ -476,6 +505,7 @@ const handler = createNextHandler( const pub = await getPubsWithRelatedValuesAndChildren({ pubId: params.pubId as PubsId, communityId: community.id, + userId: user.id, }); return { @@ -484,7 +514,7 @@ const handler = createNextHandler( }; }, replace: async ({ params, body }) => { - const { community, lastModifiedBy } = await checkAuthorization({ + const { user, community, lastModifiedBy } = await checkAuthorization({ token: { scope: ApiAccessScope.pub, type: ApiAccessType.write }, cookies: { capability: Capabilities.deletePub, @@ -522,6 +552,7 @@ const handler = createNextHandler( const pub = await getPubsWithRelatedValuesAndChildren({ pubId: params.pubId as PubsId, communityId: community.id, + userId: user.id, }); return { diff --git a/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx b/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx index ab0e39893..8a280ee8d 100644 --- a/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx +++ b/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx @@ -137,6 +137,7 @@ export default async function FormPage({ if (!community) { return notFound(); } + const { user, session } = await getLoginData(); const [form, pub, pubs, pubTypes] = await Promise.all([ getForm({ @@ -150,7 +151,7 @@ export default async function FormPage({ ) : undefined, getPubsWithRelatedValuesAndChildren( - { communityId: community.id }, + { communityId: community.id, userId: user?.id }, { limit: 30, withStage: true, @@ -165,8 +166,6 @@ export default async function FormPage({ return No form found; } - const { user, session } = await getLoginData(); - if (!user && !session) { const result = await handleFormToken({ params, @@ -208,7 +207,7 @@ export default async function FormPage({ const parentPub = pub?.parentId ? await getPubsWithRelatedValuesAndChildren( - { pubId: pub.parentId, communityId: community.id }, + { pubId: pub.parentId, communityId: community.id, userId: user?.id }, { withStage: true, withLegacyAssignee: true, withPubType: true } ) : undefined; diff --git a/core/app/c/[communitySlug]/pubs/PubList.tsx b/core/app/c/[communitySlug]/pubs/PubList.tsx index ac0706ec3..6a9dcce42 100644 --- a/core/app/c/[communitySlug]/pubs/PubList.tsx +++ b/core/app/c/[communitySlug]/pubs/PubList.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; -import type { CommunitiesId } from "db/public"; +import type { CommunitiesId, UsersId } from "db/public"; import { cn } from "utils"; import { BasicPagination } from "~/app/components/Pagination"; @@ -20,13 +20,14 @@ type PaginatedPubListProps = { * @default `/c/${communitySlug}/pubs` */ basePath?: string; + userId: UsersId; }; const PaginatedPubListInner = async (props: PaginatedPubListProps) => { const [count, pubs] = await Promise.all([ getPubsCount({ communityId: props.communityId }), getPubsWithRelatedValuesAndChildren( - { communityId: props.communityId }, + { communityId: props.communityId, userId: props.userId }, { limit: PAGE_SIZE, offset: (props.page - 1) * PAGE_SIZE, @@ -48,7 +49,14 @@ const PaginatedPubListInner = async (props: PaginatedPubListProps) => { return (
{pubs.map((pub) => { - return ; + return ( + + ); })} { + async ({ + userId, + pubId, + communityId, + }: { + userId?: UsersId; + pubId: PubsId; + communityId: CommunitiesId; + }) => { return getPubsWithRelatedValuesAndChildren( { pubId, communityId, + userId, }, { withPubType: true, @@ -97,6 +106,7 @@ export default async function Page({ const pub = await getPubsWithRelatedValuesAndChildrenCached({ pubId: params.pubId as PubsId, communityId: community.id, + userId: user.id, }); if (!pub) { diff --git a/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx b/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx index 1571dbabe..4145f4a61 100644 --- a/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx +++ b/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { Suspense } from "react"; import Link from "next/link"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import type { PubsId } from "db/public"; import { Capabilities } from "db/src/public/Capabilities"; @@ -86,6 +86,16 @@ export default async function Page({ notFound(); } + const canView = await userCan( + Capabilities.viewPub, + { type: MembershipType.pub, pubId }, + user.id + ); + + if (!canView) { + redirect(`/c/${params.communitySlug}/unauthorized`); + } + const canAddMember = await userCan( Capabilities.addPubMember, { @@ -106,6 +116,8 @@ export default async function Page({ const communityMembersPromise = selectCommunityMembers({ communityId: community.id }).execute(); const communityStagesPromise = getStages({ communityId: community.id }).execute(); + // We don't pass the userId here because we want to include related pubs regardless of authorization + // This is safe because we've already explicitly checked authorization for the root pub const pub = await getPubsWithRelatedValuesAndChildren( { pubId: params.pubId, communityId: community.id }, { @@ -165,7 +177,6 @@ export default async function Page({
) : null} -
Actions
{actions && actions.length > 0 && stage ? ( diff --git a/core/app/c/[communitySlug]/pubs/page.tsx b/core/app/c/[communitySlug]/pubs/page.tsx index 62f731c1b..db69eebc3 100644 --- a/core/app/c/[communitySlug]/pubs/page.tsx +++ b/core/app/c/[communitySlug]/pubs/page.tsx @@ -37,6 +37,7 @@ export default async function Page({ params, searchParams }: Props) { searchParams={searchParams} page={page} basePath={basePath} + userId={user.id} /> ); diff --git a/core/app/c/[communitySlug]/stages/[stageId]/page.tsx b/core/app/c/[communitySlug]/stages/[stageId]/page.tsx index f95988427..0951e40d6 100644 --- a/core/app/c/[communitySlug]/stages/[stageId]/page.tsx +++ b/core/app/c/[communitySlug]/stages/[stageId]/page.tsx @@ -93,6 +93,7 @@ export default async function Page({ fallback={} > {stages.map((stage) => ( @@ -72,6 +76,7 @@ async function StageCard({ fallback={} >
; + userId: UsersId; } & XOR<{ pub: PubRowPub }, { pubId: PubsId; communityId: CommunitiesId }>; const groupPubChildrenByPubType = (pubs: PubRowPub[]) => { @@ -78,7 +79,7 @@ const ChildHierarchy = ({ pub, communitySlug }: { pub: PubRowPub; communitySlug: const PubRow: React.FC = async (props: Props) => { const pub = props.pubId ? await getPubsWithRelatedValuesAndChildren( - { pubId: props.pubId, communityId: props.communityId }, + { pubId: props.pubId, communityId: props.communityId, userId: props.userId }, { withPubType: true, withRelatedPubs: false, diff --git a/core/app/components/pubs/PubEditor/PubEditor.tsx b/core/app/components/pubs/PubEditor/PubEditor.tsx index cbec71936..92fccf6f4 100644 --- a/core/app/components/pubs/PubEditor/PubEditor.tsx +++ b/core/app/components/pubs/PubEditor/PubEditor.tsx @@ -53,6 +53,7 @@ export async function PubEditor(props: PubEditorProps) { { pubId: props.pubId, communityId: props.communityId, + userId: user?.id, }, { withPubType: true, diff --git a/core/lib/server/pub-capabilities.db.test.ts b/core/lib/server/pub-capabilities.db.test.ts new file mode 100644 index 000000000..b452f5375 --- /dev/null +++ b/core/lib/server/pub-capabilities.db.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import { CoreSchemaType, MemberRole } from "db/public"; + +import type { Seed } from "~/prisma/seed/seedCommunity"; +import { mockServerCode } from "../__tests__/utils"; + +await mockServerCode(); + +const seed = { + community: { + name: "test-pub-capabilities", + slug: "test-pub-capabilities", + }, + users: { + admin: { + role: MemberRole.admin, + }, + editor: { + role: MemberRole.editor, + }, + stage1Editor: { + role: MemberRole.contributor, + }, + stage2Editor: { + role: MemberRole.contributor, + }, + contributor: { + role: MemberRole.contributor, + }, + minimalPubMember: { + role: MemberRole.contributor, + }, + }, + pubFields: { + Title: { schemaName: CoreSchemaType.String }, + Description: { schemaName: CoreSchemaType.String }, + "Some relation": { schemaName: CoreSchemaType.String, relation: true }, + }, + pubTypes: { + "Basic Pub": { + Title: { isTitle: true }, + "Some relation": { isTitle: false }, + }, + "Minimal Pub": { + Title: { isTitle: true }, + }, + }, + stages: { + "Stage 1": { + members: { + stage1Editor: MemberRole.editor, + }, + }, + "Stage 2": { + members: { + stage2Editor: MemberRole.editor, + }, + }, + }, + pubs: [ + { + pubType: "Basic Pub", + values: { + Title: "Some title", + }, + stage: "Stage 1", + }, + { + pubType: "Basic Pub", + values: { + Title: "Another title", + }, + stage: "Stage 2", + relatedPubs: { + "Some relation": [ + { + value: "test relation value", + pub: { + pubType: "Basic Pub", + values: { + Title: "A pub related to another Pub", + }, + }, + }, + ], + }, + }, + { + stage: "Stage 1", + pubType: "Minimal Pub", + values: { + Title: "Minimal pub", + }, + members: { + minimalPubMember: MemberRole.admin, + }, + }, + ], +} as Seed; + +describe("getPubsWithRelatedValuesAndChildren capabilities", () => { + it("should restrict pubs by visibility", async () => { + const { seedCommunity } = await import("~/prisma/seed/seedCommunity"); + const { community, pubFields, pubTypes, stages, pubs, users } = await seedCommunity(seed); + const { getPubsWithRelatedValuesAndChildren } = await import("./pub"); + + // Admins and editors of the community should see all pubs + const pubsVisibleToAdmin = await getPubsWithRelatedValuesAndChildren({ + communityId: community.id, + userId: users.admin.id, + }); + // Includes +1 related pub + expect(pubsVisibleToAdmin.length).toEqual(4); + // Do the same check for editors + const pubsVisibleToEditor = await getPubsWithRelatedValuesAndChildren({ + communityId: community.id, + userId: users.editor.id, + }); + expect(pubsVisibleToEditor.length).toEqual(4); + + // Stage member should only see stages they were added to + const pubsVisibleToStage1Editor = await getPubsWithRelatedValuesAndChildren({ + communityId: community.id, + userId: users.stage1Editor.id, + }); + const stage1 = stages["Stage 1"]; + expect( + pubsVisibleToStage1Editor.sort((a, b) => + a.title ? a.title.localeCompare(b.title || "") : -1 + ) + ).toMatchObject([ + { title: "Minimal pub", stageId: stage1.id }, + { title: "Some title", stageId: stage1.id }, + ]); + + // Check a stage that has a related pub not in the same stage. Should not get the related pub + const pubsVisibleToStage2Editor = await getPubsWithRelatedValuesAndChildren({ + communityId: community.id, + userId: users.stage2Editor.id, + }); + const stage2 = stages["Stage 2"]; + expect(pubsVisibleToStage2Editor).toMatchObject([ + { title: "Another title", stageId: stage2.id }, + ]); + + // Check a user who is normally a contributor but is admin on one pub + const pubsVisibleToPubMember = await getPubsWithRelatedValuesAndChildren({ + communityId: community.id, + userId: users.minimalPubMember.id, + }); + expect(pubsVisibleToPubMember).toMatchObject([{ title: "Minimal pub" }]); + + // Contributor should not see any pubs + const pubsVisibleToContributor = await getPubsWithRelatedValuesAndChildren({ + communityId: community.id, + userId: users.contributor.id, + }); + expect(pubsVisibleToContributor.length).toEqual(0); + }); +}); diff --git a/core/lib/server/pub.db.test.ts b/core/lib/server/pub.db.test.ts index f2d632f76..3634f7c67 100644 --- a/core/lib/server/pub.db.test.ts +++ b/core/lib/server/pub.db.test.ts @@ -6,18 +6,27 @@ import { CoreSchemaType, MemberRole } from "db/public"; import type { UnprocessedPub } from "./pub"; import { mockServerCode } from "~/lib/__tests__/utils"; -import { seedCommunity } from "~/prisma/seed/seedCommunity"; import { createLastModifiedBy } from "../lastModifiedBy"; +const { createSeed, seedCommunity } = await import("~/prisma/seed/seedCommunity"); + const { createForEachMockedTransaction } = await mockServerCode(); const { getTrx } = createForEachMockedTransaction(); -const { community, pubFields, pubTypes, stages, pubs } = await seedCommunity({ +const seed = createSeed({ community: { name: "test", slug: "test-server-pub", }, + users: { + admin: { + role: MemberRole.admin, + }, + stageEditor: { + role: MemberRole.contributor, + }, + }, pubFields: { Title: { schemaName: CoreSchemaType.String }, Description: { schemaName: CoreSchemaType.String }, @@ -33,7 +42,11 @@ const { community, pubFields, pubTypes, stages, pubs } = await seedCommunity({ }, }, stages: { - "Stage 1": {}, + "Stage 1": { + members: { + stageEditor: MemberRole.editor, + }, + }, }, pubs: [ { @@ -70,9 +83,10 @@ const { community, pubFields, pubTypes, stages, pubs } = await seedCommunity({ }, }, ], - users: {}, }); +const { community, pubFields, pubTypes, stages, pubs, users } = await seedCommunity(seed); + describe("createPubRecursive", () => { it("should be able to create a simple pub", async () => { const trx = getTrx(); @@ -404,7 +418,7 @@ describe("getPubsWithRelatedValuesAndChildren", () => { const { getPubsWithRelatedValuesAndChildren } = await import("./pub"); const rootPubId = pub.id; const pubValues = await getPubsWithRelatedValuesAndChildren( - { pubId: rootPubId, communityId: community.id }, + { pubId: rootPubId, communityId: community.id, userId: users.admin.id }, { depth: 10 } ); @@ -950,7 +964,7 @@ describe("getPubsWithRelatedValuesAndChildren", () => { const pubId = pubs[0].id as PubsId; // Add a user and make it a member of this pub - const users = [ + const newUsers = [ { email: "test@email.com", slug: "test-user", @@ -964,7 +978,7 @@ describe("getPubsWithRelatedValuesAndChildren", () => { lastName: "user2", }, ]; - const userIds = await trx.insertInto("users").values(users).returning(["id"]).execute(); + const userIds = await trx.insertInto("users").values(newUsers).returning(["id"]).execute(); await trx .insertInto("pub_memberships") .values(userIds.map(({ id }) => ({ userId: id, pubId, role: MemberRole.admin }))) @@ -973,12 +987,12 @@ describe("getPubsWithRelatedValuesAndChildren", () => { const { getPubsWithRelatedValuesAndChildren } = await import("./pub"); const pub = await getPubsWithRelatedValuesAndChildren( - { pubId, communityId: community.id }, + { pubId, communityId: community.id, userId: users.admin.id }, { withMembers: true } ); expect(pub).toMatchObject({ - members: users.map((u) => ({ ...u, role: MemberRole.admin })), + members: newUsers.map((u) => ({ ...u, role: MemberRole.admin })), }); }); diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index 7ba7044ed..634720cf3 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -22,7 +22,7 @@ import type { import type { Database } from "db/Database"; import type { CommunitiesId, - MemberRole, + MembershipCapabilitiesRole, PubFieldsId, Pubs, PubsId, @@ -35,7 +35,9 @@ import type { UsersId, } from "db/public"; import type { LastModifiedBy } from "db/types"; -import { CoreSchemaType, OperationType } from "db/public"; +import { CoreSchemaType, MemberRole, OperationType } from "db/public"; +import { Capabilities } from "db/src/public/Capabilities"; +import { MembershipType } from "db/src/public/MembershipType"; import { assert, expect } from "utils"; import type { MaybeHas, Prettify, XOR } from "../types"; @@ -515,6 +517,19 @@ export const createPubRecursiveNew = async ({ + pubId: newPub.id, + userId: userId as UsersId, + role, + })) + ) + .execute(); + } + const pubValues = valuesWithFieldIds.length ? await autoRevalidate( trx @@ -1190,18 +1205,25 @@ interface GetPubsWithRelatedValuesAndChildrenOptions extends GetManyParams, Mayb trx?: typeof db; } +// TODO: We allow calling getPubsWithRelatedValuesAndChildren with no userId so that event driven +// actions can select a pub even when no user is present (and some other scenarios where the +// filtering wouldn't make sense). We probably need to do that, but we should make it more explicit +// than just leaving out the userId to avoid accidentally letting certain routes select pubs without +// authorization checks type PubIdOrPubTypeIdOrStageIdOrCommunityId = | { pubId: PubsId; pubTypeId?: never; stageId?: never; communityId: CommunitiesId; + userId?: UsersId; } | { pubId?: never; pubTypeId?: PubTypesId; stageId?: StagesId; communityId: CommunitiesId; + userId?: UsersId; }; const DEFAULT_OPTIONS = { @@ -1288,7 +1310,7 @@ export async function getPubsWithRelatedValuesAndChildren< // we need to do this weird cast, because kysely does not support typing the selecting from a later CTE // which is possible only in a with recursive query .selectFrom("root_pubs_limited as p" as unknown as "pubs as p") - // maybe move this to root_pubs to save a join? + // TODO: can we avoid doing this join again since it's already in root pubs? .leftJoin("PubsInStages", "p.id", "PubsInStages.pubId") .$if(Boolean(withRelatedPubs), (qb) => qb @@ -1347,6 +1369,68 @@ export async function getPubsWithRelatedValuesAndChildren< ) ) .leftJoin("PubsInStages", "pubs.id", "PubsInStages.pubId") + .where((eb) => + eb.exists( + eb.selectFrom("capabilities" as any).where((ebb) => { + type Inner = + typeof ebb extends ExpressionBuilder< + infer Thing, + any + > + ? Thing + : never; + const eb = ebb as ExpressionBuilder< + Inner & { + capabilities: { + membId: string; + type: MembershipType; + role: MembershipCapabilitiesRole; + }; + }, + any + >; + + return eb.or([ + eb.and([ + eb( + "capabilities.type", + "=", + MembershipType.stage + ), + eb( + "capabilities.membId", + "=", + eb.ref("PubsInStages.stageId") + ), + ]), + eb.and([ + eb( + "capabilities.type", + "=", + MembershipType.pub + ), + eb( + "capabilities.membId", + "=", + eb.ref("pubs.id") + ), + ]), + eb.and([ + eb( + "capabilities.type", + "=", + MembershipType.community + ), + eb( + "capabilities.membId", + "=", + props.communityId + ), + ]), + ]); + }) + ) + ) .$if(Boolean(withRelatedPubs), (qb) => qb .leftJoin("pub_values", (join) => @@ -1391,6 +1475,113 @@ export async function getPubsWithRelatedValuesAndChildren< ) ) ) + .with("stage_ms", (db) => + db + .selectFrom("stage_memberships") + .$if(Boolean(props.userId), (qb) => + qb + .where("stage_memberships.userId", "=", props.userId!) + .select([ + "role", + "stageId as membId", + sql`'stage'::"MembershipType"`.as("type"), + ]) + ) + .$castTo<{ + role: MembershipCapabilitiesRole; + membId: string; + type: MembershipType; + }>() + ) + .with("pub_ms", (db) => + db + .selectFrom("pub_memberships") + .$if(Boolean(props.userId), (qb) => + qb + .where("pub_memberships.userId", "=", props.userId!) + .select([ + "role", + "pubId as membId", + sql`'pub'::"MembershipType"`.as("type"), + ]) + ) + .$castTo<{ + role: MembershipCapabilitiesRole; + membId: string; + type: MembershipType; + }>() + ) + .with("community_ms", (db) => + db + .selectFrom("community_memberships") + .$if(Boolean(props.userId), (qb) => + qb + .where("community_memberships.userId", "=", props.userId!) + .where("community_memberships.communityId", "=", props.communityId) + .select([ + "role", + "communityId as membId", + sql`'community'::"MembershipType"`.as("type"), + ]) + ) + // Add a fake community admin role when selecting without a userId + .$if(!Boolean(props.userId), (qb) => + qb.select((eb) => [ + sql`${MemberRole.admin}::"MemberRole"`.as("role"), + eb.val(props.communityId).as("membId"), + sql`'community'::"MembershipType"`.as("type"), + ]) + ) + + .$castTo<{ + role: MembershipCapabilitiesRole; + membId: string; + type: MembershipType; + }>() + ) + .with("memberships", (cte) => + cte + .selectFrom("community_ms") + .selectAll("community_ms") + .$if(Boolean(props.userId), (qb) => + qb + .union((qb) => qb.selectFrom("pub_ms").selectAll("pub_ms")) + .union((qb) => qb.selectFrom("stage_ms").selectAll("stage_ms")) + // Add fake community admin role when user is a superadmin + .union((qb) => + qb + .selectFrom("users") + .where("users.id", "=", props.userId!) + .where("users.isSuperAdmin", "=", true) + .select((eb) => [ + sql`${MemberRole.admin}::"MemberRole"`.as( + "role" + ), + eb.val(props.communityId).as("membId"), + sql`'community'::"MembershipType"`.as( + "type" + ), + ]) + ) + ) + ) + .with("capabilities", (cte) => + cte + .selectFrom("memberships") + .innerJoin("membership_capabilities", (join) => + join.on((eb) => + eb.and([ + eb("membership_capabilities.role", "=", eb.ref("memberships.role")), + eb("membership_capabilities.type", "=", eb.ref("memberships.type")), + ]) + ) + ) + .where("membership_capabilities.capability", "in", [ + Capabilities.viewPub, + Capabilities.viewStage, + ]) + .selectAll("memberships") + ) // this CTE finds the top level pubs and limits the result // counter intuitively, this is CTE is referenced in the above `withRecursive` call, despite // appearing after it. This is allowed in Postgres. See https://www.postgresql.org/docs/current/sql-select.html#SQL-WITH @@ -1400,11 +1591,37 @@ export async function getPubsWithRelatedValuesAndChildren< .selectFrom("pubs") .selectAll("pubs") .where("pubs.communityId", "=", props.communityId) + .leftJoin("PubsInStages", "pubs.id", "PubsInStages.pubId") + .select("PubsInStages.stageId") + .where((eb) => + eb.exists( + eb + .selectFrom("capabilities") + .where((eb) => + eb.or([ + eb.and([ + eb("capabilities.type", "=", MembershipType.stage), + eb( + "capabilities.membId", + "=", + eb.ref("PubsInStages.stageId") + ), + ]), + eb.and([ + eb("capabilities.type", "=", MembershipType.pub), + eb("capabilities.membId", "=", eb.ref("pubs.id")), + ]), + eb.and([ + eb("capabilities.type", "=", MembershipType.community), + eb("capabilities.membId", "=", props.communityId), + ]), + ]) + ) + ) + ) .$if(Boolean(props.pubId), (qb) => qb.where("pubs.id", "=", props.pubId!)) .$if(Boolean(props.stageId), (qb) => - qb - .innerJoin("PubsInStages", "pubs.id", "PubsInStages.pubId") - .where("PubsInStages.stageId", "=", props.stageId!) + qb.where("PubsInStages.stageId", "=", props.stageId!) ) .$if(Boolean(props.pubTypeId), (qb) => qb.where("pubs.pubTypeId", "=", props.pubTypeId!) @@ -1413,7 +1630,7 @@ export async function getPubsWithRelatedValuesAndChildren< .$if(Boolean(offset), (qb) => qb.offset(offset!)) ) .selectFrom("pub_tree as pt") - .select((eb) => [ + .select([ "pt.pubId as id", "pt.parentId", "pt.pubTypeId", @@ -1435,7 +1652,7 @@ export async function getPubsWithRelatedValuesAndChildren< .$if(Boolean(fieldSlugs), (qb) => qb.where("pub_fields.slug", "in", fieldSlugs!) ) - .select((eb) => [ + .select([ "pv.id as id", "pv.fieldId", "pv.value", diff --git a/core/prisma/exampleCommunitySeeds/arcadia.ts b/core/prisma/exampleCommunitySeeds/arcadia.ts index d67cfd660..5960283a5 100644 --- a/core/prisma/exampleCommunitySeeds/arcadia.ts +++ b/core/prisma/exampleCommunitySeeds/arcadia.ts @@ -387,7 +387,7 @@ export const seedArcadia = async (communityId?: CommunitiesId) => { }, stages: { Articles: { - members: ["arcadia-user-1"], + members: { "arcadia-user-1": MemberRole.editor }, }, // these stages are mostly here to provide slightly easier grouping of the relevant pubs Authors: {}, @@ -397,7 +397,7 @@ export const seedArcadia = async (communityId?: CommunitiesId) => { Navigations: {}, Tags: {}, "Stage 2": { - members: ["arcadia-user-1"], + members: { "arcadia-user-1": MemberRole.editor }, }, }, stageConnections: { diff --git a/core/prisma/exampleCommunitySeeds/croccroc.ts b/core/prisma/exampleCommunitySeeds/croccroc.ts index 748cb98cd..f3deb5cf1 100644 --- a/core/prisma/exampleCommunitySeeds/croccroc.ts +++ b/core/prisma/exampleCommunitySeeds/croccroc.ts @@ -139,7 +139,7 @@ export async function seedCroccroc(communityId?: CommunitiesId) { }, stages: { Submitted: { - members: ["new"], + members: { new: MemberRole.contributor }, actions: [ { action: Action.email, @@ -153,10 +153,10 @@ export async function seedCroccroc(communityId?: CommunitiesId) { ], }, "Ask Author for Consent": { - members: ["new"], + members: { new: MemberRole.contributor }, }, "To Evaluate": { - members: ["new"], + members: { new: MemberRole.contributor }, }, "Under Evaluation": {}, "In Production": {}, diff --git a/core/prisma/seed/seedCommunity.db.test.ts b/core/prisma/seed/seedCommunity.db.test.ts index b4e04dccd..8ac140e2a 100644 --- a/core/prisma/seed/seedCommunity.db.test.ts +++ b/core/prisma/seed/seedCommunity.db.test.ts @@ -59,7 +59,7 @@ describe("seedCommunity", () => { }, stages: { "Stage 1": { - members: ["test"], + members: { hih: MemberRole.contributor }, actions: [ { action: Action.email, @@ -268,7 +268,7 @@ describe("seedCommunity", () => { expect(seededCommunity.stages, "stages").toMatchObject({ "Stage 1": { - members: ["test"], + members: { hih: MemberRole.contributor }, name: "Stage 1", order: "aa", }, diff --git a/core/prisma/seed/seedCommunity.ts b/core/prisma/seed/seedCommunity.ts index b38c8ded8..fb26048df 100644 --- a/core/prisma/seed/seedCommunity.ts +++ b/core/prisma/seed/seedCommunity.ts @@ -101,7 +101,9 @@ type StagesInitializer = Record< string, { id?: StagesId; - members?: (keyof U)[]; + members?: { + [M in keyof U]?: MemberRole; + }; actions?: ActionInstanceInitializer[]; } >; @@ -173,7 +175,9 @@ type PubInitializer< * The members of the pub. * Users are referenced by their keys in the users object. */ - members?: (keyof U)[]; + members?: { + [M in keyof U]?: MemberRole; + }; children?: PubInitializer[]; /** * Relations can be specified inline @@ -340,6 +344,12 @@ const makePubInitializerMatchCreatePubRecursiveInput = < ]) ); + const members = Object.fromEntries( + Object.entries(pub.members ?? {}).map( + ([slug, role]) => [findBySlug(users, slug)?.id!, role!] as const + ) + ) as Record; + const rootPubId = pub.id ?? (crypto.randomUUID() as PubsId); const relatedPubs = pub.relatedPubs @@ -383,6 +393,7 @@ const makePubInitializerMatchCreatePubRecursiveInput = < stageId: stageId, values, parentId: pub.parentId, + members, children: pub.children && makePubInitializerMatchCreatePubRecursiveInput({ @@ -897,14 +908,18 @@ export async function seedCommunity< })); const stageMembers = consolidatedStages - .flatMap( - (stage, idx) => - stage.members?.map((member) => ({ - stage, - user: findBySlug(usersWithMemberShips, member as string)!, - })) ?? [] - ) - .filter((stageMember) => stageMember.user.member != undefined); + .flatMap((stage, idx) => { + if (!stage.members) return []; + + return Object.entries(stage.members)?.map(([member, role]) => ({ + stage, + user: findBySlug(usersWithMemberShips, member as string)!, + role, + })); + }) + .filter( + (stageMember) => stageMember.user.member != undefined && stageMember.role != undefined + ); const stageMemberships = stageMembers.length > 0 @@ -912,7 +927,7 @@ export async function seedCommunity< .insertInto("stage_memberships") .values((eb) => stageMembers.map((stageMember) => ({ - role: stageMember.user.member?.role ?? MemberRole.editor, + role: stageMember.role!, stageId: stageMember.stage.id, userId: stageMember.user.id, })) @@ -1181,3 +1196,5 @@ export const createSeed = < pubs?: PI; forms?: F; }) => props; + +export type Seed = Parameters[0]; diff --git a/packages/contracts/src/resources/site.ts b/packages/contracts/src/resources/site.ts index f39bc645d..568ca8708 100644 --- a/packages/contracts/src/resources/site.ts +++ b/packages/contracts/src/resources/site.ts @@ -14,11 +14,13 @@ import type { Stages, StagesId, Users, + UsersId, } from "db/public"; import { communitiesIdSchema, communityMembershipsSchema, coreSchemaTypeSchema, + memberRoleSchema, pubFieldsSchema, pubsIdSchema, pubsSchema, @@ -27,6 +29,7 @@ import { pubValuesSchema, stagesIdSchema, stagesSchema, + usersIdSchema, usersSchema, } from "db/public"; @@ -41,6 +44,7 @@ export type CreatePubRequestBodyWithNullsNew = z.infer; + members?: Record; }; export const safeUserSchema = usersSchema.omit({ passwordHash: true }).strict(); @@ -55,6 +59,9 @@ const CreatePubRequestBodyWithNullsWithStageId = CreatePubRequestBodyWithNullsBa }) ) ), + members: ( + z.record(usersIdSchema, memberRoleSchema) as z.ZodType> + ).optional(), }); export const CreatePubRequestBodyWithNullsNew: z.ZodType = diff --git a/packages/db/src/public/PublicSchema.ts b/packages/db/src/public/PublicSchema.ts index 868813d65..492dde393 100644 --- a/packages/db/src/public/PublicSchema.ts +++ b/packages/db/src/public/PublicSchema.ts @@ -37,6 +37,28 @@ import type { StagesTable } from "./Stages"; import type { UsersTable } from "./Users"; export interface PublicSchema { + api_access_tokens: ApiAccessTokensTable; + + api_access_logs: ApiAccessLogsTable; + + api_access_permissions: ApiAccessPermissionsTable; + + form_elements: FormElementsTable; + + sessions: SessionsTable; + + community_memberships: CommunityMembershipsTable; + + pub_memberships: PubMembershipsTable; + + stage_memberships: StageMembershipsTable; + + form_memberships: FormMembershipsTable; + + membership_capabilities: MembershipCapabilitiesTable; + + pub_values_history: PubValuesHistoryTable; + _prisma_migrations: PrismaMigrationsTable; users: UsersTable; @@ -82,26 +104,4 @@ export interface PublicSchema { action_runs: ActionRunsTable; forms: FormsTable; - - api_access_tokens: ApiAccessTokensTable; - - api_access_logs: ApiAccessLogsTable; - - api_access_permissions: ApiAccessPermissionsTable; - - form_elements: FormElementsTable; - - sessions: SessionsTable; - - community_memberships: CommunityMembershipsTable; - - pub_memberships: PubMembershipsTable; - - stage_memberships: StageMembershipsTable; - - form_memberships: FormMembershipsTable; - - membership_capabilities: MembershipCapabilitiesTable; - - pub_values_history: PubValuesHistoryTable; }