diff --git a/core/.prettierignore b/core/.prettierignore index a95e8cf197..2ed14a18ef 100644 --- a/core/.prettierignore +++ b/core/.prettierignore @@ -1 +1,2 @@ -app/c/\[communitySlug\]/developers/docs/stoplight.styles.css \ No newline at end of file +app/c/\[communitySlug\]/developers/docs/stoplight.styles.css +vitest-bench.local.json \ No newline at end of file diff --git a/core/actions/_lib/runActionInstance.ts b/core/actions/_lib/runActionInstance.ts index d640ac4924..15b0299f37 100644 --- a/core/actions/_lib/runActionInstance.ts +++ b/core/actions/_lib/runActionInstance.ts @@ -180,6 +180,7 @@ const _runActionInstance = async ( communityId: pub.communityId as CommunitiesId, lastModifiedBy, actionRunId: args.actionRunId, + userId: isActionUserInitiated ? args.userId : undefined, }); if (isClientExceptionOptions(result)) { diff --git a/core/actions/email/run.ts b/core/actions/email/run.ts index 12775e254a..126313f0ba 100644 --- a/core/actions/email/run.ts +++ b/core/actions/email/run.ts @@ -1,7 +1,5 @@ "use server"; -import { jsonObjectFrom } from "kysely/helpers/postgres"; - import type { CommunityMembershipsId } from "db/public"; import { logger } from "logger"; import { assert, expect } from "utils"; @@ -11,95 +9,109 @@ import type { RenderWithPubContext } from "~/lib/server/render/pub/renderWithPub import { db } from "~/kysely/database"; import { getCommunitySlug } from "~/lib/server/cache/getCommunitySlug"; import * as Email from "~/lib/server/email"; +import { maybeWithTrx } from "~/lib/server/maybeWithTrx"; +import { coalesceMemberships, selectCommunityMemberships } from "~/lib/server/member"; import { renderMarkdownWithPub } from "~/lib/server/render/pub/renderMarkdownWithPub"; import { isClientException } from "~/lib/serverActions"; import { defineRun } from "../types"; -export const run = defineRun(async ({ pub, config, args, communityId }) => { - try { - const communitySlug = await getCommunitySlug(); - const recipientEmail = args?.recipientEmail ?? config.recipientEmail; - const recipientMemberId = (args?.recipientMember ?? config.recipientMember) as - | CommunityMembershipsId - | undefined; +export const run = defineRun( + async ({ pub, config, args, communityId, actionRunId, userId }) => { + try { + const result = await maybeWithTrx(db, async (trx) => { + const communitySlug = await getCommunitySlug(); + const recipientEmail = args?.recipientEmail ?? config.recipientEmail; + const recipientMemberId = (args?.recipientMember ?? config.recipientMember) as + | CommunityMembershipsId + | undefined; - assert( - recipientEmail !== undefined || recipientMemberId !== undefined, - "No email recipient was specified" - ); + assert( + recipientEmail !== undefined || recipientMemberId !== undefined, + "No recipient was specified for email" + ); - let recipient: RenderWithPubContext["recipient"] | undefined; + let recipient: RenderWithPubContext["recipient"] | undefined; - if (recipientMemberId !== undefined) { - recipient = await db - .selectFrom("community_memberships") - .select((eb) => [ - "community_memberships.id", - jsonObjectFrom( - eb - .selectFrom("users") - .whereRef("users.id", "=", "community_memberships.userId") - .selectAll("users") - ) - .$notNull() - .as("user"), - ]) - .where("id", "=", recipientMemberId) - .executeTakeFirstOrThrow( - () => new Error(`Could not find member with ID ${recipientMemberId}`) - ); - } + if (recipientMemberId !== undefined) { + const memberships = await selectCommunityMemberships({ + id: recipientMemberId, + }).execute(); + if (!memberships.length) { + throw new Error(`Could not find member with ID ${recipientMemberId}`); + } - const renderMarkdownWithPubContext = { - communityId, - communitySlug, - recipient, - pub, - } as RenderWithPubContext; + const membership = coalesceMemberships(memberships); - const html = await renderMarkdownWithPub( - args?.body ?? config.body, - renderMarkdownWithPubContext - ); - const subject = await renderMarkdownWithPub( - args?.subject ?? config.subject, - renderMarkdownWithPubContext, - true - ); + recipient = { + id: membership.id, + user: membership.user, + }; + } else if (recipientEmail !== undefined) { + recipient = { + email: recipientEmail, + }; + } else { + throw new Error("No recipient was specified"); + } - const result = await Email.generic({ - to: expect(recipient?.user.email ?? recipientEmail), - subject, - html, - }).send(); + const renderMarkdownWithPubContext = { + communityId, + communitySlug, + recipient, + pub, + inviter: { + userId, + actionRunId, + }, + trx, + } as RenderWithPubContext; - if (isClientException(result)) { - logger.error({ - msg: "An error occurred while sending an email", - error: result.error, - pub, - config, - args, - renderMarkdownWithPubContext, - }); - } else { - logger.info({ - msg: "Successfully sent email", - pub, - config, - args, - renderMarkdownWithPubContext, - }); - } + const html = await renderMarkdownWithPub( + args?.body ?? config.body, + renderMarkdownWithPubContext + ); + const subject = await renderMarkdownWithPub( + args?.subject ?? config.subject, + renderMarkdownWithPubContext, + true + ); + + const result = await Email.generic({ + to: expect(recipient.email ?? recipient.user.email), + subject, + html, + }).send(); - return result; - } catch (error) { - logger.error({ msg: "Failed to send email", error }); + if (isClientException(result)) { + logger.error({ + msg: "An error occurred while sending an email", + error: result.error, + pub, + config, + args, + renderMarkdownWithPubContext, + }); + } else { + logger.info({ + msg: "Successfully sent email", + pub, + config, + args, + renderMarkdownWithPubContext, + }); + } - return { - title: "Failed to Send Email", - error: error.message, - cause: error, - }; + return result; + }); + return result; + } catch (error) { + logger.error({ msg: "Failed to send email", error }); + + return { + title: "Failed to Send Email", + error: error.message, + cause: error, + }; + } } -}); +); diff --git a/core/actions/types.ts b/core/actions/types.ts index 884f72ab91..0fd40a32a0 100644 --- a/core/actions/types.ts +++ b/core/actions/types.ts @@ -7,6 +7,7 @@ import type { ActionRunsId, CommunitiesId, StagesId, + UsersId, } from "db/public"; import type { LastModifiedBy } from "db/types"; import type { Dependency, FieldConfig, FieldConfigItem } from "ui/auto-form"; @@ -47,6 +48,10 @@ export type RunProps = */ lastModifiedBy: LastModifiedBy; actionRunId: ActionRunsId; + /** + * The user ID of the user who initiated the action, if any + */ + userId?: UsersId; } : never; diff --git a/core/app/(user)/login/Notice.tsx b/core/app/(user)/login/Notice.tsx deleted file mode 100644 index 3c06466b05..0000000000 --- a/core/app/(user)/login/Notice.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Alert, AlertDescription, AlertTitle } from "ui/alert"; -import { AlertCircle } from "ui/icon"; - -export const Notice = ({ - variant, - title, - description, -}: { - variant: "default" | "destructive"; - title: string | React.ReactNode; - description?: string | React.ReactNode; -}) => ( - - - {title} - {description && {description}} - -); diff --git a/core/app/(user)/login/page.tsx b/core/app/(user)/login/page.tsx index 9e24d33b32..2ed0e2fd34 100644 --- a/core/app/(user)/login/page.tsx +++ b/core/app/(user)/login/page.tsx @@ -3,14 +3,17 @@ import { redirect } from "next/navigation"; import { LAST_VISITED_COOKIE } from "~/app/components/LastVisitedCommunity/constants"; import { getLoginData } from "~/lib/authentication/loginData"; +import { Notice } from "../../components/Notice"; import LoginForm from "./LoginForm"; export default async function Login({ searchParams, }: { - searchParams: { + searchParams: Promise<{ error?: string; - }; + notice?: string; + body?: string; + }>; }) { const { user } = await getLoginData(); @@ -27,9 +30,13 @@ export default async function Login({ redirect("/settings"); } + const { notice, error, body } = await searchParams; + return (
+ {notice && } + {error && } {/*
Don't have an account?{" "} { const validatedTokenPromise = validateToken(token); const currentSessionCookie = (await cookies()).get(lucia.sessionCookieName)?.value; @@ -77,21 +70,22 @@ export async function GET(req: NextRequest) { if (tokenSettled.status === "rejected") { logger.debug({ msg: "Token validation failed", reason: tokenSettled.reason }); - if (!(tokenSettled.reason instanceof InvalidTokenError)) { - logger.error({ - msg: `Token validation unexpectedly failed with reason: ${tokenSettled.reason}`, - reason: tokenSettled.reason, - }); - throw tokenSettled.reason; + if (tokenSettled.reason instanceof InvalidTokenError) { + return handleInvalidToken({ + redirectTo, + tokenType: tokenSettled.reason.tokenType, + reason: tokenSettled.reason.reason, + token, + }); } - return handleInvalidToken({ - redirectTo, - tokenType: tokenSettled.reason.tokenType, - reason: tokenSettled.reason.reason, - token, + logger.error({ + msg: `Token validation unexpectedly failed with reason: ${tokenSettled.reason}`, + reason: tokenSettled.reason, }); + + throw tokenSettled.reason; } const currentSession = @@ -127,4 +121,43 @@ export async function GET(req: NextRequest) { }); return redirectToURL(redirectTo, req); +}; + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams; + const token = searchParams.get("token"); + const redirectTo = searchParams.get("redirectTo"); + + if (!redirectTo) { + logger.error({ + msg: "Magic link did not contain a redirectTo", + url: req.nextUrl, + cookies: req.cookies.getAll(), + }); + return redirectToLogin({ + loginNotice: { + type: "error", + title: "Your magic link is invalid", + }, + }); + } + + if (token) { + return handleTokenFlow(token, redirectTo, req); + } + + logger.error({ + msg: "Magic link did not contain a token", + url: req.nextUrl, + cookies: req.cookies.getAll(), + }); + + return redirectToLogin({ + loginNotice: { + type: "error", + title: "Your magic link is invalid", + // maybe to expressive to users + body: "You magic link did not contain a magic link token.", + }, + }); } diff --git a/core/app/(user)/reset/page.tsx b/core/app/(user)/reset/page.tsx index bc943b7f44..e70bb19af2 100644 --- a/core/app/(user)/reset/page.tsx +++ b/core/app/(user)/reset/page.tsx @@ -4,22 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "ui/ca import { getLoginData } from "~/lib/authentication/loginData"; import ResetForm from "./ResetForm"; -export default async function Page({ - searchParams, -}: { - searchParams: - | { - access_token: string; - type: string; - token_type: string; - } - | { - error: string; - error_code: string; - error_description: string; - } - | {}; -}) { +export default async function Page() { // TODO: add reset token validation const { user, session } = await getLoginData({ allowedSessions: [AuthTokenType.passwordReset], diff --git a/core/app/(user)/signup/page.tsx b/core/app/(user)/signup/page.tsx index 0652af2dd1..26bf6e6d35 100644 --- a/core/app/(user)/signup/page.tsx +++ b/core/app/(user)/signup/page.tsx @@ -1,9 +1,17 @@ import { AuthTokenType } from "db/public"; +import { SignupForm } from "~/app/components/Signup/SignupForm"; +import { legacySignup } from "~/lib/authentication/actions"; import { getLoginData } from "~/lib/authentication/loginData"; -import { LegacySignupForm } from "../../components/Signup/LegacySignupForm"; -export default async function Page() { +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ + redirectTo: string; + }>; +}) { + const { redirectTo } = await searchParams; const { user, session } = await getLoginData({ allowedSessions: [AuthTokenType.signup], }); @@ -16,10 +24,19 @@ export default async function Page() {
); } + const signupAction = legacySignup.bind(null, user.id); return (
- +
); } diff --git a/core/app/(user)/verify/ResendVerificationButton.tsx b/core/app/(user)/verify/ResendVerificationButton.tsx index e0a91a671a..bc1809fcd5 100644 --- a/core/app/(user)/verify/ResendVerificationButton.tsx +++ b/core/app/(user)/verify/ResendVerificationButton.tsx @@ -18,7 +18,7 @@ export const ResendVerificationButton = ({ const sendVerifyEmail = useServerAction(sendVerifyEmailMail); const handleResend = async () => { - setStatus("loading"); + setStatus("pending"); const result = await sendVerifyEmail({ email, redirectTo }); if ("error" in result) { setStatus("error"); @@ -32,7 +32,7 @@ export const ResendVerificationButton = ({ state={status} onClick={handleResend} idleText="Resend verification email" - loadingText="Sending..." + pendingText="Sending..." /> ); }; 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 f6785d9697..9064ea5bcf 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 @@ -21,6 +21,7 @@ import { } from "~/app/components/forms/structural"; import { SUBMIT_ID_QUERY_PARAM } from "~/app/components/pubs/PubEditor/constants"; import { SaveStatus } from "~/app/components/pubs/PubEditor/SaveStatus"; +import { db } from "~/kysely/database"; import { getLoginData } from "~/lib/authentication/loginData"; import { getCommunityRole } from "~/lib/authentication/roles"; import { findCommunityBySlug } from "~/lib/server/community"; @@ -235,6 +236,7 @@ export default async function FormPage(props: { recipient: memberWithUser, communitySlug: params.communitySlug, pub, + trx: db, }; if (submitId && submitElement) { diff --git a/core/app/c/(public)/[communitySlug]/public/invite/AcceptRejectInvite.tsx b/core/app/c/(public)/[communitySlug]/public/invite/AcceptRejectInvite.tsx new file mode 100644 index 0000000000..0ab6a74573 --- /dev/null +++ b/core/app/c/(public)/[communitySlug]/public/invite/AcceptRejectInvite.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { ArrowRight, Check, LogIn, UserPlus, X } from "lucide-react"; +import { useForm } from "react-hook-form"; + +import type { Invite } from "db/types"; +import { MemberRole } from "db/public"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "ui/alert-dialog"; +import { Button } from "ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "ui/card"; +import { Form } from "ui/form"; + +import { FormSubmitButton } from "~/app/components/SubmitButton"; +import { useServerAction } from "~/lib/serverActions"; +import { acceptInviteAction, rejectInviteAction } from "./actions"; + +const roleToVerb = { + [MemberRole.admin]: "admin", + [MemberRole.editor]: "edit", + [MemberRole.contributor]: "contribute to", +} as const satisfies Record; + +const communityRoleToVerb = { + [MemberRole.admin]: "become an admin at", + [MemberRole.editor]: "become an editor at", + [MemberRole.contributor]: "join", +} as const satisfies Record; + +const inviteMessage = (invite: Invite) => { + let extraText = ""; + if (invite.stageId) { + extraText = ` and ${roleToVerb[invite.stageRole]} the stage ${invite.stage.name}`; + } + + if (invite.pubId) { + extraText = ` and ${roleToVerb[invite.pubRole]} ${ + // todo: proper logic for articles + invite.pub.title + ? `the Pub "${invite.pub.title}"` + : `to a(n) ${invite.pub.pubType.name}` + }`; + } + + return ( + invite.message || + `You've been invited to ${communityRoleToVerb[invite.communityRole]} ${invite.community.name}${extraText}.` + ); +}; + +type AcceptRejectInviteMode = "accept" | "needsSignup" | "needsLogin" | "complete"; + +const modeStyles = { + accept: { + bodyMessage: "To continue, you need to accept this invitation and create an account.", + button: { + text: "Accept", + loadingText: "Accepting Invitation...", + successText: "Invitation Accepted", + errorText: "Error Accepting Invitation", + icon: , + }, + }, + needsSignup: { + bodyMessage: "To continue, you need to create an account.", + button: { + text: "Create account", + loadingText: "Navigating to signup...", + successText: "Navigated to signup", + errorText: "Error navigating to signup", + icon: , + }, + }, + needsLogin: { + bodyMessage: "To continue, you need to log in.", + button: { + text: "Log in", + loadingText: "Navigating to login...", + successText: "Navigated to login", + errorText: "Error navigating to login", + icon: , + }, + }, + complete: { + bodyMessage: "You have already accepted this invitation, but signed up in a different way.", + button: { + text: "Continue", + icon: , + errorText: "Error continuing", + loadingText: "Continuing...", + successText: "Continued", + }, + hideRejectButton: true, + }, +} satisfies Record< + AcceptRejectInviteMode, + { + bodyMessage: string; + button: { + text: string; + loadingText: string; + successText: string; + errorText: string; + icon: React.ReactNode; + }; + hideRejectButton?: boolean; + } +>; + +export function AcceptRejectInvite({ + inviteToken, + redirectTo, + invite, + mode, +}: { + inviteToken: string; + invite: Invite; + redirectTo: string; + mode: AcceptRejectInviteMode; +}) { + // TODO: we should really have useServerAction return some state that keeps track of the status + const acceptInvite = useServerAction(acceptInviteAction); + const rejectInvite = useServerAction(rejectInviteAction); + + const acceptForm = useForm(); + const rejectForm = useForm(); + + const hideRejectButton = + "hideRejectButton" in modeStyles[mode] && modeStyles[mode].hideRejectButton; + + return ( +
+ + + + You've Been Invited + + + {inviteMessage(invite)} + + + + +
+

+ {modeStyles[mode].bodyMessage} +

+
+
+ +
+ { + acceptInvite({ + inviteToken: inviteToken, + redirectTo: redirectTo, + }); + })} + className="flex-grow" + > + + {modeStyles[mode].button.icon} + {modeStyles[mode].button.text} + + } + pendingText={modeStyles[mode].button.loadingText} + successText={modeStyles[mode].button.successText} + errorText={modeStyles[mode].button.errorText} + /> + + + {hideRejectButton ? null : ( + + + + + + + Reject Invitation + + + Are you sure you want to reject this invitation? You will no + longer be able to accept it. + + + Go back + +
+ { + rejectInvite({ + inviteToken: inviteToken, + redirectTo: redirectTo, + }); + })} + > + + + + Reject + + } + pendingText="Rejecting Invitation..." + successText="Invitation Rejected" + errorText="Error Rejecting Invitation" + /> + +
+ +
+
+
+ )} +
+
+
+ ); +} diff --git a/core/app/c/(public)/[communitySlug]/public/invite/InviteStatuses.tsx b/core/app/c/(public)/[communitySlug]/public/invite/InviteStatuses.tsx new file mode 100644 index 0000000000..001a4684e3 --- /dev/null +++ b/core/app/c/(public)/[communitySlug]/public/invite/InviteStatuses.tsx @@ -0,0 +1,179 @@ +import type { User } from "lucia"; + +import Link from "next/link"; +import { AlertCircle, CheckCircle, XCircle } from "lucide-react"; + +import { InviteStatus } from "db/public"; +import { Button } from "ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "ui/card"; + +import { InviteService } from "~/lib/server/invites/InviteService"; +import { constructCommunitySignupLink } from "~/lib/server/navigation/redirects"; +import { WrongUserLoggedIn } from "./WrongUserLoggedIn"; + +type InvalidInviteProps = { + message: string; + description?: string; + variant?: "error" | "success" | "warning"; + redirectTo?: { + label: string; + href: string; + }; +}; + +const defaultProps = { + message: "Invalid invite", + description: "The invite you are trying to use is invalid.", + variant: "error", +} as const satisfies InvalidInviteProps; + +const styles = { + success: { + bg: "bg-success/10", + text: "text-success", + icon: CheckCircle, + }, + warning: { + bg: "bg-warning/10", + text: "text-warning", + icon: AlertCircle, + }, + error: { + bg: "bg-destructive/10", + text: "text-destructive", + icon: XCircle, + }, +}; + +export const InviteStatusCard = (inputProps: InvalidInviteProps) => { + const props = { + ...defaultProps, + ...inputProps, + }; + + const IconComponent = styles[props.variant].icon; + + const bgColor = styles[props.variant].bg; + const textColor = styles[props.variant].text; + + return ( +
+ + +
+ +
+ + {props.message} + +
+ {props.description && ( + +

{props.description}

+
+ )} + + {props.redirectTo ? ( + + ) : ( + + )} + +
+
+ ); +}; + +// switch (error.status) { +// case InviteStatus.completed: +// return ( +// +// ); + +export const InvalidInviteError = ({ + error, + redirectTo, +}: { + error: InviteService.InviteError; + redirectTo?: string; +}) => { + switch (error.code) { + case "NOT_FOUND": + return ; + case "INVALID_TOKEN": + return ; + case "EXPIRED": + return ; + case "REJECTED": + return ( + + ); + case "REVOKED": + return ( + + ); + case "NOT_READY": + return ( + + ); + case "NOT_FOR_USER": + return ; + default: + return ; + } +}; + +export const NoInviteFound = () => { + return ( +
+ + +
+ +
+ + No Invite Found + +
+ +

+ No invite was provided. +
+ Please check the link you received. +

+
+ + + + + +
+
+ ); +}; diff --git a/core/app/c/(public)/[communitySlug]/public/invite/WrongUserLoggedIn.tsx b/core/app/c/(public)/[communitySlug]/public/invite/WrongUserLoggedIn.tsx new file mode 100644 index 0000000000..280129c984 --- /dev/null +++ b/core/app/c/(public)/[communitySlug]/public/invite/WrongUserLoggedIn.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { usePathname, useSearchParams } from "next/navigation"; + +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "ui/card"; +import { AlertCircle } from "ui/icon"; + +import LogoutButton from "~/app/components/LogoutButton"; + +export const WrongUserLoggedIn = ({ email }: { email?: string }) => { + const path = usePathname(); + const searchParams = useSearchParams(); + + const currentUrl = `${path}?${searchParams.toString()}`; + + return ( +
+ + +
+ +
+ + Wrong Account + +
+ +

+ {email + ? `This invite is for ${email}. You are currently logged in with a different account.` + : "You are logged in with an account that doesn't match this invite."} +

+

+ Please log out and try again +

+
+ + + Log Out + + +
+
+ ); +}; diff --git a/core/app/c/(public)/[communitySlug]/public/invite/actions.ts b/core/app/c/(public)/[communitySlug]/public/invite/actions.ts new file mode 100644 index 0000000000..4330340eb7 --- /dev/null +++ b/core/app/c/(public)/[communitySlug]/public/invite/actions.ts @@ -0,0 +1,226 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { z } from "zod"; + +import { AuthTokenType, InviteStatus } from "db/public"; +import { tryCatch } from "utils/try-catch"; + +import type { SignupFormSchema } from "~/app/components/Signup/schema"; +import { db } from "~/kysely/database"; +import { lucia } from "~/lib/authentication/lucia"; +import { createLastModifiedBy } from "~/lib/lastModifiedBy"; +import { findCommunityBySlug } from "~/lib/server/community"; +import { defineServerAction } from "~/lib/server/defineServerAction"; +import { InviteService } from "~/lib/server/invites/InviteService"; +import { maybeWithTrx } from "~/lib/server/maybeWithTrx"; +import { redirectToCommunitySignup, redirectToLogin } from "~/lib/server/navigation/redirects"; +import { setUserPassword, updateUser } from "~/lib/server/user"; + +// Schema for the invite token +const inviteTokenSchema = z.string().min(1); + +export const acceptInviteAction = defineServerAction(async function acceptInvite({ + inviteToken, + redirectTo, +}: { + inviteToken: string; + redirectTo: string; +}) { + const tokenResult = inviteTokenSchema.safeParse(inviteToken); + if (!tokenResult.success) { + return { + success: false, + error: "Invalid invite token", + }; + } + + const community = await findCommunityBySlug(); + + if (!community) { + return { + success: false, + error: "Community not found", + }; + } + + const { invite, user } = await InviteService.getValidInviteForLoggedInUser(inviteToken); + + if (!user) { + if (!invite.user.isProvisional) { + // redirect to login, then back to invite, then to the correct page + const inviteUrl = await InviteService.createInviteLink(invite, { + redirectTo, + absolute: false, + }); + + redirectToLogin({ + redirectTo: inviteUrl, + loginNotice: { + type: "notice", + title: "You need to log in in order to accept this invite", + }, + }); + throw new Error("User invite"); + } + + // If user is not logged in, create a signup link and redirect to it + // we do mark the invite as accepted, but not completed + await InviteService.setInviteStatus( + invite, + InviteStatus.accepted, + createLastModifiedBy({ userId: invite.userId }), + db + ); + + await redirectToCommunitySignup({ + redirectTo, + inviteToken, + notice: { + type: "notice", + title: "You need to create an account in order to accept this invite", + }, + }); + throw new Error("Never should have come here"); + } + + // If user is logged in, accept the invite + const [err, result] = await tryCatch(InviteService.completeInvite(invite, db)); + + if (err) { + return { + success: false, + error: err.message, + }; + } + + redirect(redirectTo); +}); + +export const rejectInviteAction = defineServerAction(async function rejectInvite({ + inviteToken, + redirectTo = "/", +}: { + inviteToken: string; + redirectTo?: string; +}) { + const tokenResult = inviteTokenSchema.safeParse(inviteToken); + if (!tokenResult.success) { + return { + success: false, + error: "Invalid invite token", + }; + } + + const [err, inviteResult] = await tryCatch( + InviteService.getValidInviteForLoggedInUser(inviteToken) + ); + + if (err) { + return { + success: false, + error: err.message, + }; + } + + const { user, invite } = inviteResult; + + const [rejectErr, rejectResult] = await tryCatch(InviteService.rejectInvite(invite)); + + if (rejectErr) { + return { + success: false, + error: rejectErr.message, + }; + } + + redirectToLogin({ + loginNotice: { + type: "notice", + title: "You have rejected the invite", + }, + }); +}); + +export const signupThroughInvite = defineServerAction(async function signupThroughInvite( + inviteToken, + { + redirectTo = "/", + ...props + }: { + redirectTo?: string; + } & SignupFormSchema +) { + const [err, inviteResult] = await tryCatch( + InviteService.getValidInviteForLoggedInUser(inviteToken) + ); + + if (err) { + return { + success: false, + error: err.message, + }; + } + + const { user, invite } = inviteResult; + + if (props.email !== invite.user.email) { + return { + success: false, + error: "Email does not match invite email. You must use the email you were invited with.", + }; + } + + const [addUserErr, newUser] = await tryCatch( + maybeWithTrx(db, async (trx) => { + const newUser = await updateUser( + { + firstName: props.firstName, + lastName: props.lastName, + id: invite.user.id, + isProvisional: false, + isVerified: true, + }, + trx + ); + + await setUserPassword( + { + userId: newUser.id, + password: props.password, + }, + trx + ); + + await InviteService.completeInvite(invite, trx, newUser); + + return newUser; + }) + ); + + if (addUserErr && addUserErr instanceof InviteService.InviteError) { + return { + success: false, + error: addUserErr.message, + }; + } + + if (addUserErr) { + throw addUserErr; + } + + const newSession = await lucia.createSession(newUser.id, { + type: AuthTokenType.generic, + }); + const newSessionCookie = lucia.createSessionCookie(newSession.id); + const cookieStore = await cookies(); + cookieStore.set(newSessionCookie.name, newSessionCookie.value, newSessionCookie.attributes); + + redirect(redirectTo); + + return { + success: true, + report: "Please check your email to verify your account!", + }; +}); diff --git a/core/app/c/(public)/[communitySlug]/public/invite/page.tsx b/core/app/c/(public)/[communitySlug]/public/invite/page.tsx new file mode 100644 index 0000000000..45876eac46 --- /dev/null +++ b/core/app/c/(public)/[communitySlug]/public/invite/page.tsx @@ -0,0 +1,130 @@ +import { redirect } from "next/navigation"; + +import { InviteStatus } from "db/public"; +import { logger } from "logger"; +import { tryCatch } from "utils/try-catch"; + +import { InviteService } from "~/lib/server/invites/InviteService"; +import { + constructCommunitySignupLink, + constructLoginLink, +} from "~/lib/server/navigation/redirects"; +import { AcceptRejectInvite } from "./AcceptRejectInvite"; +import { InvalidInviteError, InviteStatusCard, NoInviteFound } from "./InviteStatuses"; + +export default async function InvitePage(props: { + params: Promise<{ communitySlug: string }>; + searchParams: Promise<{ invite?: string; redirectTo?: string }>; +}) { + const searchParams = await props.searchParams; + // If no invite token provided, show error page + if (!searchParams.invite) { + return ; + } + + const inviteToken = searchParams.invite; + const redirectTo = searchParams.redirectTo || "/"; + + const [err, inviteResult] = await tryCatch( + InviteService.getValidInviteForLoggedInUser(inviteToken) + ); + + if (err && !(err instanceof InviteService.InviteError)) { + // Log unexpected errors + logger.error({ + msg: "Unexpected error processing invite", + err, + inviteToken, + }); + + return ; + } + + if (err) { + return ; + } + + const { user, invite } = inviteResult; + + // user has accepted the invite, but did not complete signup + if (invite.status === InviteStatus.accepted) { + if (user) { + // somehow the user has already signed up but the invite is still not completed + // this can only happen if the user clicked "Accept invite", did not complete signup, + // and then created an account in a different way + // eg by accepting a different invite or by creating a public account + + return ( + + ); + } + + const signupLink = await constructCommunitySignupLink({ + redirectTo: redirectTo, + inviteToken, + notice: { + title: "Finish sign up to accept invite.", + type: "notice", + }, + }); + + return ( + + ); + } + + if (invite.status === InviteStatus.completed) { + if (user) { + // just redirect them to the redirectTo url + return redirect(redirectTo); + } + + const loginLink = constructLoginLink({ + redirectTo: redirectTo, + loginNotice: { + title: "Login to continue to destination", + type: "notice", + }, + }); + + return ( + + ); + } + + const mode = user ? "accept" : invite.user?.isProvisional ? "needsSignup" : "needsLogin"; + + // server will handle the redirect + return ( + + ); +} diff --git a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx index 4367fab630..92a190b0ca 100644 --- a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx +++ b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx @@ -1,20 +1,40 @@ -import { notFound, redirect, RedirectType, unstable_rethrow } from "next/navigation"; +import type { User } from "lucia"; +import { notFound, redirect } from "next/navigation"; +import * as Sentry from "@sentry/nextjs"; + +import type { Communities } from "db/public"; +import type { Invite } from "db/types"; import { MemberRole } from "db/public"; import { logger } from "logger"; +import { assert } from "utils"; +import { tryCatch } from "utils/try-catch"; +import type { NoticeParams } from "~/app/components/Notice"; +import { Notice } from "~/app/components/Notice"; import { JoinCommunityForm } from "~/app/components/Signup/JoinCommunityForm"; -import { PublicSignupForm } from "~/app/components/Signup/PublicSignupForm"; +import { SignupForm } from "~/app/components/Signup/SignupForm"; +import { publicSignup } from "~/lib/authentication/actions"; import { getLoginData } from "~/lib/authentication/loginData"; import { findCommunityBySlug } from "~/lib/server/community"; +import { InviteService } from "~/lib/server/invites/InviteService"; import { publicSignupsAllowed } from "~/lib/server/user"; +import { signupThroughInvite } from "../invite/actions"; +import { InvalidInviteError } from "../invite/InviteStatuses"; +import { WrongUserLoggedIn } from "../invite/WrongUserLoggedIn"; export default async function Page({ params, searchParams, }: { params: Promise<{ communitySlug: string }>; - searchParams: Promise<{ redirectTo?: string }>; + searchParams: Promise<{ + redirectTo?: string; + notice?: string; + error?: string; + body?: string; + inviteToken?: string; + }>; }) { const [community, { user }] = await Promise.all([findCommunityBySlug(), getLoginData()]); @@ -26,33 +46,40 @@ export default async function Page({ notFound(); } - const isAllowedToSignup = await publicSignupsAllowed(community.id); - - if (!isAllowedToSignup) { - // this community does not allow public signups - notFound(); - } - - const { redirectTo } = await searchParams; + const { redirectTo, notice, error, body, inviteToken } = await searchParams; - if (user) { - if (user.memberships.some((m) => m.communityId === community.id)) { - redirect(redirectTo ?? `/c/${community.slug}/stages`); - } - - // TODO: figure this out based on the invite - const joinRole = MemberRole.contributor; + const noticeTitle = notice || error; + const noticeParams = noticeTitle + ? ({ type: notice ? "notice" : "error", title: noticeTitle, body } satisfies NoticeParams) + : undefined; + // invited signup + if (inviteToken) { + assert(redirectTo, "Redirect to is required for invite signup"); + // handle invite flow return ( - - + + ); } + // public signup flow + const isAllowedToSignup = await publicSignupsAllowed(community.id); + + if (!isAllowedToSignup) { + // this community does not allow public signups + notFound(); + } + return ( - - + + ); } @@ -61,10 +88,92 @@ export default async function Page({ * just a wrapper that centers stuff on the page. * could be put in a layout later */ -const Wrapper = ({ children }: { children: React.ReactNode }) => { +const Wrapper = ({ children, notice }: { children: React.ReactNode; notice?: NoticeParams }) => { return ( -
+
+ {notice && } {children}
); }; + +const PublicSignupFlow = ({ + user, + community, + redirectTo, +}: { + user: User | null; + community: Communities; + redirectTo?: string; +}) => { + if (user) { + if (user.memberships.some((m) => m.communityId === community.id)) { + redirect(redirectTo ?? `/c/${community.slug}/stages`); + } + + // TODO: figure this out based on the invite + const joinRole = MemberRole.contributor; + + return ; + } + + return ; +}; + +const InviteSignupFlow = async ({ + user, + community, + redirectTo, + inviteToken, +}: { + inviteToken: string; + user: User | null; + community: Communities; + redirectTo: string; +}) => { + const [inviteErr, invite] = await tryCatch(InviteService.getValidInvite(inviteToken)); + + if (inviteErr && !(inviteErr instanceof InviteService.InviteError)) { + // do certain things + logger.error({ + msg: "Invite error", + inviteErr, + }); + throw new Error("Invite error"); + } + + if (inviteErr) { + return ; + } + + if (user) { + // user is somehow already logged in, lets check if they are the invitee + const [err] = tryCatch(() => InviteService.assertUserIsInvitee(invite, user)); + + if (!err) { + // they are the correct invitee, so lets redirect them back to the invite page + const redirectUrl = await InviteService.createInviteLink(invite, { + redirectTo, + absolute: false, + }); + redirect(redirectUrl); + } + + if (!(err instanceof InviteService.InviteError)) { + // not sure how this happened bruh + logger.error({ + msg: "Invite error", + err, + }); + Sentry.captureException(err); + return ; + } + + // not sure how this happened bruh + return ; + } + + const signupFn = signupThroughInvite.bind(null, inviteToken); + + return ; +}; diff --git a/core/app/c/[communitySlug]/members/page.tsx b/core/app/c/[communitySlug]/members/page.tsx index aa2aa22117..0be968c539 100644 --- a/core/app/c/[communitySlug]/members/page.tsx +++ b/core/app/c/[communitySlug]/members/page.tsx @@ -8,7 +8,7 @@ import type { TableMember } from "./getMemberTableColumns"; import { AddMemberDialog } from "~/app/components/Memberships/AddMemberDialog"; import { getPageLoginData } from "~/lib/authentication/loginData"; import { userCan } from "~/lib/authorization/capabilities"; -import { firstRoleIsHigher } from "~/lib/authorization/rolesRanking"; +import { compareMemberRoles } from "~/lib/authorization/rolesRanking"; import { findCommunityBySlug } from "~/lib/server/community"; import { getMembershipForms } from "~/lib/server/form"; import { selectAllCommunityMemberships } from "~/lib/server/member"; @@ -80,7 +80,7 @@ export default async function Page(props: { dedupedMembersMap.set(member.id, member); } else { const m = dedupedMembersMap.get(member.id); - if (m && firstRoleIsHigher(member.role, m.role)) { + if (m && compareMemberRoles(member.role, ">", m.role)) { dedupedMembersMap.set(member.id, m); } } diff --git a/core/app/components/LogoutButton.tsx b/core/app/components/LogoutButton.tsx index 55075c3352..8bcc3dd84f 100644 --- a/core/app/components/LogoutButton.tsx +++ b/core/app/components/LogoutButton.tsx @@ -7,14 +7,27 @@ import { Button } from "ui/button"; import { LogOut } from "ui/icon"; import { cn } from "utils"; +import type { NoticeParams } from "./Notice"; import * as actions from "~/lib/authentication/actions"; import { useServerAction } from "~/lib/serverActions"; -const LogoutButton = React.forwardRef( - ({ className, ...props }, ref) => { +type LogoutButtonProps = ButtonProps & { + /** + * @default "/login" + */ + destination?: string; + /** + * Notice to display after logging out. + */ + notice?: NoticeParams; + redirectTo?: string; +}; + +const LogoutButton = React.forwardRef( + ({ className, redirectTo, destination, notice, ...props }, ref) => { const runLogout = useServerAction(actions.logout); const handleSignout = async () => { - await runLogout(); + await runLogout({ redirectTo, destination, notice }); }; return ( diff --git a/core/app/components/Memberships/MembersList.tsx b/core/app/components/Memberships/MembersList.tsx index a125ef5a78..e8cf8065f5 100644 --- a/core/app/components/Memberships/MembersList.tsx +++ b/core/app/components/Memberships/MembersList.tsx @@ -6,7 +6,7 @@ import type { UsersId } from "db/public"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; import type { MembersListProps, TargetId } from "./types"; -import { firstRoleIsHigher } from "~/lib/authorization/rolesRanking"; +import { compareMemberRoles } from "~/lib/authorization/rolesRanking"; import { RemoveMemberButton } from "./RemoveMemberButton"; import { RoleSelect } from "./RoleSelect"; @@ -24,7 +24,7 @@ export const MembersList = ({ dedupedMembers.set(member.id, member); } else { const m = dedupedMembers.get(member.id); - if (m && firstRoleIsHigher(member.role, m.role)) { + if (m && compareMemberRoles(member.role, ">", m.role)) { dedupedMembers.set(member.id, m); } } diff --git a/core/app/components/Notice.tsx b/core/app/components/Notice.tsx new file mode 100644 index 0000000000..d8a2ce544f --- /dev/null +++ b/core/app/components/Notice.tsx @@ -0,0 +1,27 @@ +import { Alert, AlertDescription, AlertTitle } from "ui/alert"; +import { AlertCircle } from "ui/icon"; +import { cn } from "utils"; + +export type NoticeParams = { + type: "error" | "notice"; + title: string; + body?: string; +}; + +export const Notice = ({ + type, + title, + body, + className, +}: { + className?: string; +} & NoticeParams) => ( + svg]:static", className)} + > + + {title} + {body && {body}} + +); diff --git a/core/app/components/Signup/LegacySignupForm.tsx b/core/app/components/Signup/LegacySignupForm.tsx deleted file mode 100644 index 0e71ce71bb..0000000000 --- a/core/app/components/Signup/LegacySignupForm.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import type { Static } from "@sinclair/typebox"; - -import { useCallback } from "react"; -import { useSearchParams } from "next/navigation"; - -import type { Users } from "db/public"; - -import type { SignupFormSchema } from "./schema"; -import { legacySignup } from "~/lib/authentication/actions"; -import { useServerAction } from "~/lib/serverActions"; -import { BaseSignupForm } from "./BaseSignupForm"; - -export function LegacySignupForm(props: { - user: Pick; -}) { - const signup = useServerAction(legacySignup); - const searchParams = useSearchParams(); - const onSubmit = useCallback(async (data: SignupFormSchema) => { - await signup({ - id: props.user.id, - firstName: data.firstName, - lastName: data.lastName, - email: data.email, - password: data.password, - redirect: searchParams.get("redirectTo"), - }); - }, []); - - return ; -} diff --git a/core/app/components/Signup/PublicSignupForm.tsx b/core/app/components/Signup/PublicSignupForm.tsx deleted file mode 100644 index f281c967ef..0000000000 --- a/core/app/components/Signup/PublicSignupForm.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { useSearchParams } from "next/navigation"; - -import type { CommunitiesId } from "db/public"; - -import type { SignupFormSchema } from "./schema"; -import { publicSignup } from "~/lib/authentication/actions"; -import { useServerAction } from "~/lib/serverActions"; -import { BaseSignupForm } from "./BaseSignupForm"; - -export function PublicSignupForm(props: { communityId: CommunitiesId; redirectTo?: string }) { - const runSignup = useServerAction(publicSignup); - - const searchParams = useSearchParams(); - const redirectTo = props.redirectTo ?? searchParams.get("redirectTo") ?? undefined; - - const handleSubmit = useCallback( - async (data: SignupFormSchema) => { - // TODO: this is not very nice UX, we should wait a sec and show a loading state - await runSignup({ - firstName: data.firstName, - lastName: data.lastName, - email: data.email, - password: data.password, - redirectTo, - communityId: props.communityId, - }); - }, - [redirectTo, runSignup] - ); - - return ; -} diff --git a/core/app/components/Signup/BaseSignupForm.tsx b/core/app/components/Signup/SignupForm.tsx similarity index 70% rename from core/app/components/Signup/BaseSignupForm.tsx rename to core/app/components/Signup/SignupForm.tsx index 72e725aa72..ef02ef9477 100644 --- a/core/app/components/Signup/BaseSignupForm.tsx +++ b/core/app/components/Signup/SignupForm.tsx @@ -1,14 +1,11 @@ "use client"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; import { typeboxResolver } from "@hookform/resolvers/typebox"; import { useForm } from "react-hook-form"; -import { registerFormats } from "schemas"; -import type { Users } from "db/public"; -import { Button } from "ui/button"; +import type { UsersId } from "db/public"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "ui/card"; import { Form, @@ -22,28 +19,55 @@ import { import { Input } from "ui/input"; import type { SignupFormSchema } from "./schema"; +import type { ClientExceptionOptions } from "~/lib/serverActions"; +import { useServerAction } from "~/lib/serverActions"; import { FormSubmitButton } from "../SubmitButton"; import { compiledSignupFormSchema } from "./schema"; -export function BaseSignupForm(props: { - user: Pick | null; - onSubmit: (data: SignupFormSchema) => Promise; +type SignupAction = (input: { + id?: UsersId; + firstName: string; + lastName: string; + email: string; + password: string; redirectTo?: string; -}) { - const searchParams = useSearchParams(); - - const redirectTo = props.redirectTo ?? searchParams.get("redirectTo"); + slug?: string; +}) => Promise< + | { + success: boolean; + report?: string; + } + | ClientExceptionOptions +>; +export function SignupForm(props: { + signupAction: SignupAction; + redirectTo?: string; + defaultValues?: Partial; + mustUseSameEmail?: boolean; +}) { const resolver = useMemo(() => typeboxResolver(compiledSignupFormSchema), []); const form = useForm({ resolver, - defaultValues: { ...(props?.user ?? {}), lastName: props.user?.lastName ?? undefined }, + defaultValues: props.defaultValues, }); + const runSignup = useServerAction(props.signupAction); + + const handleSubmit = useCallback( + async (data: SignupFormSchema) => { + const result = await runSignup({ + ...data, + redirectTo: props.redirectTo, + }); + }, + [runSignup, props.redirectTo] + ); + return (
- + Sign Up @@ -88,8 +112,9 @@ export function BaseSignupForm(props: { Email - If you change this, we will ask you to confirm your - email again. + {props.mustUseSameEmail + ? "You must enter the same email you were invited with." + : "If you change this, we will ask you to confirm your email again."}
Or{" "} sign in diff --git a/core/app/components/SubmitButton.tsx b/core/app/components/SubmitButton.tsx index 672add049c..acd457d22d 100644 --- a/core/app/components/SubmitButton.tsx +++ b/core/app/components/SubmitButton.tsx @@ -1,100 +1,92 @@ +import type { MutationStatus } from "@tanstack/react-query"; import type { FormState } from "react-hook-form"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { CheckCircle, Loader2, XCircle } from "lucide-react"; +import type { ButtonProps } from "ui/button"; import { Button } from "ui/button"; import { cn } from "utils"; -export type ButtonState = "idle" | "loading" | "success" | "error"; +export type ButtonState = MutationStatus; type SubmitButtonProps = { - // direct control props - state?: ButtonState; - isSubmitting?: boolean; - isSubmitSuccessful?: boolean; - isSubmitError?: boolean; - - // form integration - formState?: FormState; - // customization - idleText?: string; - loadingText?: string; - successText?: string; - errorText?: string; + idleText?: React.ReactNode; + pendingText?: React.ReactNode; + successText?: React.ReactNode; + errorText?: React.ReactNode; + + "data-testid"?: string; // button props className?: string; - onClick?: () => void; type?: "button" | "submit" | "reset"; -}; +} & ( + | { + state: ButtonState; + // direct control props + isSubmitting?: never; + isSubmitSuccessful?: never; + isSubmitError?: never; + } + | { + /** + * cannot be used together with direct control props + */ + state?: never; + // direct control props + isSubmitting?: boolean; + isSubmitSuccessful?: boolean; + isSubmitError?: boolean; + } +); export const SubmitButton = ({ state, isSubmitting, isSubmitSuccessful, isSubmitError, - formState, idleText = "Submit", - loadingText = "Submitting...", + pendingText = "Submitting...", successText = "Success!", errorText = "Error", className = "", onClick, type = "submit", -}: SubmitButtonProps) => { + ...props +}: ButtonProps & SubmitButtonProps) => { const [buttonState, setButtonState] = useState("idle"); const [errorTimeout, setErrorTimeout] = useState(null); + const setErrorState = useCallback(() => { + setButtonState("error"); + if (errorTimeout) clearTimeout(errorTimeout); + const timeout = setTimeout(() => setButtonState("idle"), 2000); + setErrorTimeout(timeout); + }, [errorTimeout]); + useEffect(() => { // determine state based on props if (state) { setButtonState(state); - return; - } - - if (formState) { - if (formState.isSubmitting) { - setButtonState("loading"); - return; - } - - if (formState.isSubmitSuccessful) { - setButtonState("success"); - return; - } - - if (formState.errors && Object.keys(formState.errors).length > 0) { - setButtonState("error"); - - // reset error state after 2 seconds - if (errorTimeout) clearTimeout(errorTimeout); - const timeout = setTimeout(() => setButtonState("idle"), 2000); - setErrorTimeout(timeout); - return; + if (state === "error") { + setErrorState(); } - - setButtonState("idle"); return; } - // direct prop control if (isSubmitting) { - setButtonState("loading"); - } else if (isSubmitError) { - setButtonState("error"); - - // reset error state after 2 seconds - if (errorTimeout) clearTimeout(errorTimeout); - const timeout = setTimeout(() => setButtonState("idle"), 2000); - setErrorTimeout(timeout); + setButtonState("pending"); } else if (isSubmitSuccessful) { setButtonState("success"); - } else { - setButtonState("idle"); + } else if (isSubmitError) { + setErrorState(); } - }, [state, formState, isSubmitting, isSubmitSuccessful, isSubmitError]); + + setButtonState("idle"); + return; + }, [state, isSubmitting, isSubmitSuccessful, isSubmitError]); // clean up timeout on unmount useEffect(() => { @@ -105,8 +97,8 @@ export const SubmitButton = ({ const getButtonText = () => { switch (buttonState) { - case "loading": - return loadingText; + case "pending": + return pendingText; case "success": return successText; case "error": @@ -122,7 +114,7 @@ export const SubmitButton = ({ const getButtonIcon = () => { switch (buttonState) { - case "loading": + case "pending": return ; case "success": return ; @@ -139,7 +131,8 @@ export const SubmitButton = ({ className={cn(className, "transition-colors duration-500")} onClick={onClick} variant={getButtonVariant()} - disabled={buttonState === "loading"} + disabled={buttonState === "pending"} + {...props} > {getButtonIcon()} {getButtonText()} @@ -147,37 +140,16 @@ export const SubmitButton = ({ ); }; -/** - * Form submit button that automatically handles loading state - */ export const FormSubmitButton = ({ formState, - idleText = "Submit", - loadingText = "Submitting...", - successText = "Success!", - errorText = "Error", - className = "", -}: { - formState: FormState; - /** - * Default text. - * - * @default "Submit" - */ - idleText?: string; - loadingText?: string; - successText?: string; - errorText?: string; - className?: string; -}) => { + ...props +}: ButtonProps & Omit & { formState: FormState }) => { return ( 0)} + {...props} /> ); }; diff --git a/core/app/components/pubs/PubEditor/PubEditor.tsx b/core/app/components/pubs/PubEditor/PubEditor.tsx index c32894a765..d4acabedda 100644 --- a/core/app/components/pubs/PubEditor/PubEditor.tsx +++ b/core/app/components/pubs/PubEditor/PubEditor.tsx @@ -262,6 +262,7 @@ export async function PubEditor(props: PubEditorProps) { recipient: memberWithUser as RenderWithPubContext["recipient"], communitySlug: community.slug, pub: pub as RenderWithPubContext["pub"], + trx: db, } satisfies RenderWithPubContext; await hydrateMarkdownElements({ diff --git a/core/lib/authentication/actions.ts b/core/lib/authentication/actions.ts index 425a4aea72..f3fb683edb 100644 --- a/core/lib/authentication/actions.ts +++ b/core/lib/authentication/actions.ts @@ -18,6 +18,7 @@ import { AuthTokenType, MemberRole } from "db/public"; import { logger } from "logger"; import type { Prettify } from "../types"; +import type { NoticeParams } from "~/app/components/Notice"; import { compiledSignupFormSchema } from "~/app/components/Signup/schema"; import { db } from "~/kysely/database"; import { isUniqueConstraintError } from "~/kysely/errors"; @@ -36,6 +37,7 @@ import { LAST_VISITED_COOKIE } from "../../app/components/LastVisitedCommunity/c import { findCommunityBySlug } from "../server/community"; import * as Email from "../server/email"; import { insertCommunityMemberships, selectCommunityMemberships } from "../server/member"; +import { redirectToLogin } from "../server/navigation/redirects"; import { invalidateTokensForUser } from "../server/token"; import { SignupErrors } from "./errors"; import { getLoginData } from "./loginData"; @@ -119,12 +121,15 @@ export const loginWithPassword = defineServerAction(async function loginWithPass if (props.redirectTo && /^\/\w+/.test(props.redirectTo)) { redirect(props.redirectTo); } - await redirectUser(user.memberships); }); -export const logout = defineServerAction(async function logout() { - const { session } = await validateRequest(); +export const logout = defineServerAction(async function logout(props: { + redirectTo?: string; + destination?: string; + notice?: NoticeParams; +}) { + const { session } = await getLoginData(); if (!session) { return { @@ -137,7 +142,27 @@ export const logout = defineServerAction(async function logout() { const sessionCookie = lucia.createBlankSessionCookie(); (await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); - redirect("/login"); + const destinationPath = props.destination ?? "/login"; + const searchParams = new URLSearchParams(); + + if (destinationPath) { + if (props.notice) { + searchParams.set("notice", JSON.stringify(props.notice)); + } + + if (props.redirectTo) { + searchParams.set("redirectTo", props.redirectTo); + } + + redirect(`${destinationPath}${searchParams.size ? `?${searchParams.toString()}` : ""}`); + } + + redirectToLogin({ + loginNotice: { + type: "notice", + title: "You have successfully logged out.", + }, + }); }); export const sendForgotPasswordMail = defineServerAction( @@ -344,19 +369,17 @@ export const publicSignup = defineServerAction(async function signup(props: { password: string; redirectTo?: string; slug?: string; - role?: MemberRole; - communityId: CommunitiesId; }) { - const [isAllowedSignup, community, { user }] = await Promise.all([ - publicSignupsAllowed(props.communityId), - findCommunityBySlug(), - getLoginData(), - ]); - + const community = await findCommunityBySlug(); if (!community) { return SignupErrors.COMMUNITY_NOT_FOUND({ communityName: "unknown" }); } + const [isAllowedSignup, { user }] = await Promise.all([ + publicSignupsAllowed(community.id), + getLoginData(), + ]); + if (user) { redirect(`/c/${community.slug}/public/join?redirectTo=${props.redirectTo}`); } @@ -410,7 +433,7 @@ export const publicSignup = defineServerAction(async function signup(props: { { userId: newUser.id, communityId: community.id, - role: props.role ?? MemberRole.contributor, + role: MemberRole.contributor, forms: [], //TODO: make these customizable }, trx @@ -458,14 +481,16 @@ export const publicSignup = defineServerAction(async function signup(props: { /** * flow for when a user has been invited to a community already */ -export const legacySignup = defineServerAction(async function signup(props: { - id: UsersId; - firstName: string; - lastName: string; - email: string; - password: string; - redirect: string | null; -}) { +export const legacySignup = defineServerAction(async function signup( + userId: UsersId, + props: { + firstName: string; + lastName: string; + email: string; + password: string; + redirectTo?: string | null; + } +) { const { user, session } = await getLoginData({ allowedSessions: [AuthTokenType.signup], }); @@ -473,7 +498,7 @@ export const legacySignup = defineServerAction(async function signup(props: { if (!user) { captureException(new Error("User tried to signup without existing"), { user: { - id: props.id, + id: userId, firstName: props.firstName, lastName: props.lastName, email: props.email, @@ -484,10 +509,10 @@ export const legacySignup = defineServerAction(async function signup(props: { }; } - if (user.id !== props.id) { + if (user.id !== userId) { captureException(new Error("User tried to signup with a different id"), { user: { - id: props.id, + id: userId, firstName: props.firstName, lastName: props.lastName, email: props.email, @@ -504,7 +529,7 @@ export const legacySignup = defineServerAction(async function signup(props: { const changedEmail = user.email !== props.email; const updatedUser = await updateUser( { - id: props.id, + id: userId, firstName: props.firstName, lastName: props.lastName, email: props.email, @@ -517,7 +542,7 @@ export const legacySignup = defineServerAction(async function signup(props: { await setUserPassword( { - userId: props.id, + userId, password: props.password, }, trx @@ -537,7 +562,7 @@ export const legacySignup = defineServerAction(async function signup(props: { if ("needsVerification" in updatedUser && updatedUser.needsVerification) { const verifyEmailResult = await _sendVerifyEmailMail({ email: updatedUser.email, - redirectTo: props.redirect ?? undefined, + redirectTo: props.redirectTo ?? undefined, }); if (verifyEmailResult.error) { @@ -562,17 +587,13 @@ export const legacySignup = defineServerAction(async function signup(props: { newSessionCookie.attributes ); - if (props.redirect) { - redirect(props.redirect); + if (props.redirectTo) { + redirect(props.redirectTo); } await redirectUser(); + + // typescript cannot sense Promise not returning + return "" as never; }); -export const invitedSignup = defineServerAction(async function signup(props: { - id: UsersId; - inviteToken: string; - firstName: string; - lastName: string; - email: string; - password: string; -}) {}); +// for invite signup, see the app/c/(public)/[communitySlug]/public/invite/actions.ts diff --git a/core/lib/authentication/createMagicLink.ts b/core/lib/authentication/createMagicLink.ts index 085249a209..dc9e05027d 100644 --- a/core/lib/authentication/createMagicLink.ts +++ b/core/lib/authentication/createMagicLink.ts @@ -21,5 +21,9 @@ export const createMagicLink = async (options: NativeMagicLinkOptions, trx = db) trx ); - return `${env.PUBPUB_URL}/magic-link?token=${token}&redirectTo=${encodeURIComponent(options.path)}`; + return constructMagicLink(token, options.path); +}; + +export const constructMagicLink = (token: string, path: `/${string}`) => { + return `${env.PUBPUB_URL}/magic-link?token=${token}&redirectTo=${encodeURIComponent(path)}`; }; diff --git a/core/lib/authentication/loginData.ts b/core/lib/authentication/loginData.ts index ea1576069f..42b72436aa 100644 --- a/core/lib/authentication/loginData.ts +++ b/core/lib/authentication/loginData.ts @@ -1,34 +1,49 @@ import "server-only"; +import type { Session, User } from "lucia"; + import { cache } from "react"; import { redirect } from "next/navigation"; import { getPathname } from "@nimpl/getters/get-pathname"; import { AuthTokenType } from "db/public"; +import { expect } from "utils"; +import type { LoginRedirectOpts } from "../server/navigation/redirects"; import type { ExtraSessionValidationOptions } from "./lucia"; +import { redirectToLogin, redirectToVerify } from "../server/navigation/redirects"; import { validateRequest } from "./lucia"; +/** + * Get the users login data based on the session cookie + */ export const getLoginData = cache(async (opts?: ExtraSessionValidationOptions) => { return validateRequest(opts); }); -export const getPageLoginData = cache(async () => { - const loginData = await getLoginData({ - allowedSessions: [AuthTokenType.generic, AuthTokenType.verifyEmail], - }); - - if (!loginData.user) { - const pathname = getPathname(); - redirect(pathname ? `/login?redirectTo=${encodeURIComponent(pathname)}` : "/login"); - } - - if (loginData.session && loginData.session.type === AuthTokenType.verifyEmail) { - const pathname = getPathname(); - redirect(pathname ? `/verify?redirectTo=${encodeURIComponent(pathname)}` : "/verify"); +/** + * Get the login data for the current page, and redirect to the login page if the user is not logged in. + */ +export const getPageLoginData = cache( + async (opts?: ExtraSessionValidationOptions & LoginRedirectOpts) => { + const loginData = await getLoginData({ + ...opts, + allowedSessions: [AuthTokenType.generic, AuthTokenType.verifyEmail], + }); + + if (!loginData.user) { + redirectToLogin(opts); + } + + if (loginData.session && loginData.session.type === AuthTokenType.verifyEmail) { + const pathname = getPathname(); + redirectToVerify({ + redirectTo: expect(pathname, "pathname is missing for redirectToVerify").toString(), + }); + } + + return loginData; } - - return loginData; -}); +); export type LoginData = Awaited>; diff --git a/core/lib/authorization/rolesRanking.ts b/core/lib/authorization/rolesRanking.ts index e9b1e9b0c4..7032cbb4ba 100644 --- a/core/lib/authorization/rolesRanking.ts +++ b/core/lib/authorization/rolesRanking.ts @@ -1,15 +1,15 @@ import { MemberRole } from "db/public"; -export const rolesRanking = { +export const MemberRoleRanking = { [MemberRole.admin]: 2, [MemberRole.editor]: 1, [MemberRole.contributor]: 0, -}; +} as const; export const getHighestRole = (memberships: T) => { const highestRole = memberships.reduce( (highestRole, m) => { - if (!highestRole || rolesRanking[m.role] > rolesRanking[highestRole]) { + if (!highestRole || compareMemberRoles(m.role, ">", highestRole)) { return m.role; } return highestRole; @@ -20,5 +20,35 @@ export const getHighestRole = (memberships: T) return highestRole; }; -export const firstRoleIsHigher = (firstRole: MemberRole, secondRole: MemberRole) => - rolesRanking[firstRole] > rolesRanking[secondRole]; +/** + * Compare two member roles + * @param a - The first member role + * @param operator - The operator to use + * @param b - The second member role + * @returns true if the operator is true for the two roles, false otherwise + */ +export const compareMemberRoles = ( + a: MemberRole, + operator: ">" | ">=" | "<" | "<=" | "==" | "!=", + b: MemberRole +) => { + const aRank = MemberRoleRanking[a]; + const bRank = MemberRoleRanking[b]; + + switch (operator) { + case ">": + return aRank > bRank; + case ">=": + return aRank >= bRank; + case "<": + return aRank < bRank; + case "<=": + return aRank <= bRank; + case "==": + return aRank === bRank; + case "!=": + return aRank !== bRank; + default: + return false; + } +}; diff --git a/core/lib/server/email.tsx b/core/lib/server/email.tsx index c7508a18af..4a7b88769a 100644 --- a/core/lib/server/email.tsx +++ b/core/lib/server/email.tsx @@ -1,10 +1,11 @@ +import type { SignupInviteProps } from "emails"; import type { SendMailOptions } from "nodemailer"; import { render } from "@react-email/render"; -import { PasswordReset, RequestLinkToForm, SignupInvite, VerifyEmail } from "emails"; +import { Invite, PasswordReset, RequestLinkToForm, VerifyEmail } from "emails"; -import type { Communities, MemberRole, MembershipType, Users } from "db/public"; -import { AuthTokenType } from "db/public"; +import type { Communities, MembershipType, Users } from "db/public"; +import { AuthTokenType, MemberRole } from "db/public"; import { logger } from "logger"; import type { XOR } from "../types"; @@ -26,7 +27,6 @@ export const DEFAULT_OPTIONS = { name: env.MAILGUN_SMTP_FROM_NAME ?? "PubPub Team", } as const; -// export class Email { function buildSend(emailPromise: () => Promise) { const func = send.bind(null, emailPromise); @@ -149,7 +149,10 @@ function inviteToForm() { // TODO: } -export function signupInvite( +/** + * @deprecated use SignupInvite instead + */ +export function _legacy_signupInvite( props: { user: Pick; community: Pick; @@ -174,11 +177,20 @@ export function signupInvite( ); const email = await render( - + You have been invited to become{" "} + {props.role === MemberRole.contributor ? "a" : "an"}{" "} + {props.role} of the {props.membership.type}{" "} + {props.membership.name} on PubPub. Click the button below to finish + your registration and join {props.community.name} on PubPub. + + } /> ); @@ -190,6 +202,27 @@ export function signupInvite( }); } +export function signupInvite( + props: SignupInviteProps & { + to: string | string[]; + /** + * @default "Join {community.name} on PubPub" + */ + subject?: string; + }, + trx = db +) { + return buildSend(async () => { + const email = await render(); + + return { + to: props.to, + html: email, + subject: props.subject ?? `Join ${props.community.name} on PubPub`, + }; + }); +} + export function requestAccessToForm( props: { community: Pick; diff --git a/core/lib/server/form.ts b/core/lib/server/form.ts index c8189823fa..cbfaa10b26 100644 --- a/core/lib/server/form.ts +++ b/core/lib/server/form.ts @@ -242,7 +242,7 @@ export type FormInviteLinkProps = XOR<{ formSlug: string }, { formId: FormsId }> communityId: CommunitiesId; }; -export const createFormInviteLink = async (props: FormInviteLinkProps) => { +export const createFormInviteLink = async (props: FormInviteLinkProps, trx = db) => { const formPromise = getForm({ communityId: props.communityId, ...(props.formId !== undefined ? { id: props.formId } : { slug: props.formSlug }), @@ -273,12 +273,15 @@ export const createFormInviteLink = async (props: FormInviteLinkProps) => { pubId: props.pubId, }); - const magicLink = await createMagicLink({ - userId: user.id, - path: formPath, - expiresAt: createExpiresAtDate(props.expiresInDays), - type: AuthTokenType.generic, - }); + const magicLink = await createMagicLink( + { + userId: user.id, + path: formPath, + expiresAt: createExpiresAtDate(props.expiresInDays), + type: AuthTokenType.generic, + }, + trx + ); return magicLink; }; diff --git a/core/lib/server/invites/InviteBuilder.db.test.ts b/core/lib/server/invites/InviteBuilder.db.test.ts new file mode 100644 index 0000000000..2befd9bd98 --- /dev/null +++ b/core/lib/server/invites/InviteBuilder.db.test.ts @@ -0,0 +1,209 @@ +import { beforeAll, describe, expect, expectTypeOf, it } from "vitest"; + +import type { PubsId, PubTypes, Stages } from "db/public"; +import { + CoreSchemaType, + ElementType, + InputComponent, + InviteStatus, + MemberRole, + StructuralFormElement, +} from "db/public"; +import { inviteSchema } from "db/types"; + +import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; +import { mockServerCode } from "~/lib/__tests__/utils"; +import { createSeed } from "~/prisma/seed/createSeed"; + +const { createForEachMockedTransaction } = await mockServerCode(); + +const { getTrx } = createForEachMockedTransaction(); + +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 }, + "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: { + stageEditor: MemberRole.editor, + }, + }, + }, + pubs: [ + { + pubType: "Basic Pub", + values: { + Title: "Some title", + }, + stage: "Stage 1", + }, + { + pubType: "Basic Pub", + values: { + Title: "Another title", + }, + 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", + }, + }, + ], + forms: { + TestForm: { + pubType: "Basic Pub", + elements: [ + { + type: ElementType.structural, + content: "hello", + element: StructuralFormElement.p, + }, + { + type: ElementType.pubfield, + field: "Title", + component: InputComponent.textInput, + config: { + label: "title", + }, + }, + ], + }, + }, +}); + +let community: CommunitySeedOutput; + +beforeAll(async () => { + const { seedCommunity } = await import("~/prisma/seed/seedCommunity"); + community = await seedCommunity(seed); +}); + +describe("InviteBuilder", () => { + it("should be able to create an invite", async () => { + const { InviteBuilder } = await import("./InviteBuilder"); + + const invite = await InviteBuilder.inviteUser({ + email: "test@test.com", + firstName: "Test", + lastName: "User", + }) + .invitedBy({ userId: community.users.admin.id }) + .forCommunity(community.community.id) + .withRole(MemberRole.contributor) + .create(); + expect(invite).toBeDefined(); + + expect(invite).toMatchObject({ + user: { + email: "test@test.com", + firstName: "Test", + lastName: "User", + }, + token: expect.any(String), + communityId: community.community.id, + communityRole: MemberRole.contributor, + lastModifiedBy: expect.stringMatching( + /^(user|action-run|api-access-token):[0-9a-f-]{36}\|[0-9]{13}$/ + ), + status: InviteStatus.created, + lastSentAt: null, + invitedByUserId: community.users.admin.id, + }); + }); + + it("should be able to create an invite with pubOrStageFormIds", async () => { + const { InviteBuilder } = await import("./InviteBuilder"); + const invite = await InviteBuilder.inviteUser({ + email: "test@test.com", + firstName: "Test", + lastName: "User", + }) + .invitedBy({ userId: community.users.admin.id }) + .forCommunity(community.community.id) + .withRole(MemberRole.admin) + .forPub(community.pubs[0].id) + .withRole(MemberRole.contributor) + .withForms([community.forms.TestForm.id]) + .create(); + + expect(invite).toMatchObject({ + pubFormIds: [community.forms.TestForm.id], + user: { + email: "test@test.com", + firstName: "Test", + lastName: "User", + }, + }); + }); + + it("can create invites with form slugs", async () => { + const { InviteBuilder } = await import("./InviteBuilder"); + const invite = await InviteBuilder.inviteUser({ + email: "test@test.com", + firstName: "Test", + lastName: "User", + }) + .invitedBy({ userId: community.users.admin.id }) + .forCommunity(community.community.id) + .withRole(MemberRole.admin) + .withForms([community.forms.TestForm.slug]) + .forStage(community.stages["Stage 1"].id) + .withRole(MemberRole.editor) + .withForms([community.forms.TestForm.slug]) + .withMessage("Hello") + .expiresInDays(1) + .create(); + + expect(invite).toMatchObject({ + stageId: community.stages["Stage 1"].id, + stageFormIds: [community.forms.TestForm.id], + communityFormIds: [community.forms.TestForm.id], + expiresAt: expect.any(Date), + message: "Hello", + user: { + email: "test@test.com", + firstName: "Test", + lastName: "User", + }, + }); + }); +}); diff --git a/core/lib/server/invites/InviteBuilder.ts b/core/lib/server/invites/InviteBuilder.ts new file mode 100644 index 0000000000..70432d8a81 --- /dev/null +++ b/core/lib/server/invites/InviteBuilder.ts @@ -0,0 +1,293 @@ +import crypto from "node:crypto"; + +import type { UserId } from "lucia"; + +import type { ActionRunsId, CommunitiesId, FormsId, PubsId, StagesId, UsersId } from "db/public"; +import type { Invite, NewInvite } from "db/types"; +import { formsIdSchema, InviteStatus, MemberRole } from "db/public"; +import { newInviteSchema } from "db/types"; +import { expect } from "utils"; + +import type { XOR } from "~/lib/types"; +import { db } from "~/kysely/database"; +import { createLastModifiedBy } from "~/lib/lastModifiedBy"; +import { findCommunityBySlug } from "~/lib/server/community"; +import * as Email from "~/lib/server/email"; +import { maybeWithTrx } from "~/lib/server/maybeWithTrx"; +import { addUser, generateUserSlug, getUser } from "~/lib/server/user"; +import { InviteService } from "./InviteService"; + +const BYTES_LENGTH = 16; + +const DEFAULT_EXPIRES_AT = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + +// Required initial steps +export interface InvitedByStep { + invitedBy(inviter: { userId: UsersId } | { actionRunId: ActionRunsId }): CommunityStep; +} + +interface CommunityStep { + forCommunity(communityId: CommunitiesId): WithRoleStep & PubStageStep; +} + +type NextStep = OptionalStep | PubStageStep; + +// After community, can either set community role or choose a target (pub/stage) +interface WithRoleStep { + withRole(role: MemberRole): WithFormsStep & Next; +} + +interface PubStageStep extends OptionalStep { + forPub(pubId: PubsId): WithRoleStep; + forStage(stageId: StagesId): WithRoleStep; +} + +interface WithFormsStep extends OptionalStep { + withForms(formIds: FormsId[]): Next; + withForms(slugs: string[]): Next; +} + +// If pub/stage is chosen, must set role + +// All optional steps after required steps are complete +interface OptionalStep { + withMessage(message: string): OptionalStep; + expiresAt(date: Date): OptionalStep; + expiresInDays(days: number): OptionalStep; + create(trx?: typeof db): Promise; + createAndSend(input: { redirectTo: string }, trx?: typeof db): Promise; +} + +export type NewUser = { + firstName: string; + lastName: string; + email: string; +}; + +type InviteBuilderData = XOR<{ userId: UserId }, { provisionalUser: NewUser }>; + +export class InviteBuilder + implements + InvitedByStep, + CommunityStep, + WithRoleStep, + WithFormsStep, + PubStageStep, + OptionalStep +{ + private data: Partial; + + // private constructor to force using static methods + private constructor(data: InviteBuilderData) { + this.data = { + ...data, + status: InviteStatus.created, + expiresAt: DEFAULT_EXPIRES_AT, + communityRole: MemberRole.contributor, + }; + } + + /** + * Invite a user and create an account for them + */ + static inviteUser(user: NewUser): InvitedByStep; + /** + * Invite a specific user + */ + static inviteUser(user: UsersId): InvitedByStep; + static inviteUser(user: NewUser | UsersId): InvitedByStep { + const data = typeof user === "string" ? { userId: user } : { provisionalUser: user }; + const builder = new InviteBuilder(data); + + return builder; + } + + invitedBy(inviter: { userId: UsersId } | { actionRunId: ActionRunsId }): CommunityStep { + if ("userId" in inviter) { + this.data.invitedByUserId = inviter.userId; + this.data.invitedByActionRunId = null; + } else { + this.data.invitedByActionRunId = inviter.actionRunId; + this.data.invitedByUserId = null; + } + return this; + } + + forCommunity(communityId: CommunitiesId): WithRoleStep & PubStageStep { + this.data.communityId = communityId; + return this as WithRoleStep & PubStageStep; + } + + forPub(pubId: PubsId): WithRoleStep { + this.data.pubId = pubId; + this.data.stageId = null; + return this; + } + + forStage(stageId: StagesId): WithRoleStep { + this.data.stageId = stageId; + this.data.pubId = null; + return this; + } + + withRole(role: MemberRole): WithFormsStep & NextStep { + if (this.data.pubId) { + this.data.pubRole = role; + } else if (this.data.stageId) { + this.data.stageRole = role; + } else { + this.data.communityRole = role; + } + + return this; + } + + withForms(forms: FormsId[] | string[]): NextStep { + const uuids = forms.map((form) => formsIdSchema.safeParse(form).data); + + const hasUuids = uuids.some((uuid) => uuid != null); + const hasSlugs = uuids.some((slug) => slug == null); + + if (hasUuids && hasSlugs) { + throw new Error("Cannot provide both uuids and slugs"); + } + + if (this.data.pubId) { + if (hasUuids) { + this.data.pubFormIds = uuids.filter((uuid) => uuid != null) as FormsId[]; + } else { + this.data.pubFormSlugs = forms; + } + } else if (this.data.stageId) { + if (hasUuids) { + this.data.stageFormIds = uuids.filter((uuid) => uuid != null) as FormsId[]; + } else { + this.data.stageFormSlugs = forms; + } + } else { + if (hasUuids) { + this.data.communityFormIds = uuids.filter((uuid) => uuid != null) as FormsId[]; + } else { + this.data.communityFormSlugs = forms; + } + } + return this as NextStep; + } + + withMessage(message: string): OptionalStep { + this.data.message = message; + return this; + } + + expiresAt(date: Date): OptionalStep { + this.data.expiresAt = date; + return this; + } + + expiresInDays(days: number): OptionalStep { + this.data.expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + return this; + } + + private generateToken(): string { + return crypto.randomBytes(BYTES_LENGTH).toString("base64url"); + } + + private validate() { + const newData = newInviteSchema.parse(this.data) as NewInvite; + this.data = newData; + return newData; + } + + async create(trx = db): Promise { + const token = this.generateToken(); + + this.data.token = token; + + const lastModifiedBy = this.data.invitedByUserId + ? createLastModifiedBy({ userId: this.data.invitedByUserId }) + : createLastModifiedBy({ actionRunId: expect(this.data.invitedByActionRunId) }); + + this.data.lastModifiedBy = lastModifiedBy; + + const data = this.validate(); + + return InviteService._createInvite({ ...data, token: token, lastModifiedBy }, trx); + } + + async createAndSend(input: { redirectTo: string }, trx = db): Promise { + return maybeWithTrx(trx, async (trx) => { + const invitePromise = this.create(trx); + + const existingUserPromise = getUser( + this.data.userId + ? { id: this.data.userId } + : { email: expect(this.data.provisionalUser).email }, + trx + ).executeTakeFirstOrThrow(); + + const communityPromise = findCommunityBySlug(); + + const [invite, user, community] = await Promise.all([ + invitePromise, + existingUserPromise, + communityPromise, + ]); + + let toBeInvitedUserId = user?.id; + + if (!toBeInvitedUserId) { + throw new Error("User not found"); + } + + const inviteLink = await InviteService.createInviteLink(invite, { + redirectTo: input.redirectTo, + }); + + const props = invite.pubId + ? ({ + type: "pub", + pub: expect(invite.pub), + pubRole: invite.pubRole, + communityRole: invite.communityRole, + } as const) + : invite.stageId + ? ({ + type: "stage", + stage: expect(invite.stage), + stageRole: invite.stageRole, + communityRole: invite.communityRole, + } as const) + : ({ + type: "community", + communityRole: invite.communityRole, + } as const); + + await Email.signupInvite( + { + community: expect(community), + to: user.email, + inviteLink, + ...props, + }, + trx + ).send(); + + await trx + .updateTable("invites") + .set({ + status: InviteStatus.pending, + lastSentAt: new Date(), + lastModifiedBy: createLastModifiedBy( + invite.invitedByActionRunId + ? { actionRunId: invite.invitedByActionRunId } + : { userId: expect(invite.invitedByUserId) } + ), + }) + .where("id", "=", invite.id) + .execute(); + + return invite; + }); + } +} diff --git a/core/lib/server/invites/InviteService.ts b/core/lib/server/invites/InviteService.ts new file mode 100644 index 0000000000..80b4fa58ae --- /dev/null +++ b/core/lib/server/invites/InviteService.ts @@ -0,0 +1,828 @@ +import crypto from "node:crypto"; + +import type { ExpressionBuilder } from "kysely"; +import type { User } from "lucia"; + +import { jsonObjectFrom } from "kysely/helpers/postgres"; + +import type { + CommunitiesId, + FormsId, + InvitesId, + MemberRole, + PubsId, + StagesId, + UsersId, +} from "db/public"; +import type { Invite, LastModifiedBy, NewInvite } from "db/types"; +import { InviteStatus, MembershipType } from "db/public"; +import { logger } from "logger"; +import { expect } from "utils"; + +import type { SafeUser } from "../user"; +import type { InvitedByStep, NewUser } from "./InviteBuilder"; +import { db } from "~/kysely/database"; +import { compareMemberRoles } from "~/lib/authorization/rolesRanking"; +import { env } from "~/lib/env/env"; +import { createLastModifiedBy } from "~/lib/lastModifiedBy"; +import { getLoginData } from "../../authentication/loginData"; +import { autoRevalidate } from "../cache/autoRevalidate"; +import { getCommunitySlug } from "../cache/getCommunitySlug"; +import { maybeWithTrx } from "../maybeWithTrx"; +import { + coalesceMemberships, + insertCommunityMembershipsOverrideRole, + insertPubMembershipsOverrideRole, + insertStageMembershipsOverrideRole, + selectCommunityMemberships, + selectPubMemberships, + selectStageMemberships, +} from "../member"; +import { addUser, generateUserSlug, getUser, SAFE_USER_SELECT } from "../user"; +import { withInvitedFormIds } from "./helpers"; +import { InviteBuilder } from "./InviteBuilder"; + +/** + * Collection of methods for managing invites + * + * Maybe could also just be a module + */ +export namespace InviteService { + //============================================== + // Error classes + //============================================== + + export const INVITE_ERRORS = { + NOT_FOUND: "Invite not found", + NOT_VALID: "Invite not actionable", + NOT_FOR_USER: "Invite not for user", + INVALID_TOKEN: "Invalid invite token", + NOT_READY: "Invite not ready to be used", + REJECTED: "Invite has been rejected", + REVOKED: "Invite has been revoked", + EXPIRED: "Invite has expired", + USER_NOT_LOGGED_IN: "User not logged in", + INVITE_USELESS: "Invite is useless, as it would not grant the user any new permissions", + UNKNOWN: "Unknown invite error", + } as const; + + export const invalidInviteMap = { + [InviteStatus.rejected]: "REJECTED", + [InviteStatus.revoked]: "REVOKED", + [InviteStatus.created]: "NOT_READY", + [InviteStatus.accepted]: false, + [InviteStatus.pending]: false, + [InviteStatus.completed]: false, + } satisfies Record; + + export type InviteErrorType = keyof typeof INVITE_ERRORS; + + export class InviteError extends Error { + code: InviteErrorType; + status?: InviteStatus; + constructor( + code: InviteErrorType, + opts?: { + status?: InviteStatus; + additionalMessage?: string; + logContext?: Record; + } + ) { + const msg = `${code}: ${INVITE_ERRORS[code]}.${opts?.additionalMessage ?? ""}`; + if (opts?.logContext) { + // these are expected errors, so we don't want to log them as errors + logger.debug({ + msg, + ...opts.logContext, + }); + } + super(msg); + this.code = code; + this.status = opts?.status; + } + } + + export const assertUserIsInvitee = ( + invite: Invite, + user: { id: UsersId; email: string } | null + ) => { + if (!user) { + return; + } + + if (invite.userId && invite.userId !== user.id) { + throw new InviteError("NOT_FOR_USER", { + logContext: { + inviteToken: invite.token, + userId: user.id, + }, + status: invite.status, + }); + } + }; + //============================================== + + /** + * Invite someone and create an account for them + */ + export function inviteUser(user: NewUser): InvitedByStep; + /** + * Invite a specific user + */ + export function inviteUser(user: UsersId): InvitedByStep; + export function inviteUser(user: UsersId | NewUser): InvitedByStep { + if (typeof user === "string") { + return InviteBuilder.inviteUser(user); + } + return InviteBuilder.inviteUser(user); + } + + /** + * @internal + */ + export async function _getInvite(token: string, id: InvitesId, trx = db) { + return trx + .selectFrom("invites") + .where("token", "=", token) + .where("id", "=", id) + .selectAll() + .select((eb) => withInvitedFormIds(eb, "invites.id")) + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom("communities") + .select(["id", "slug", "avatar", "name"]) + .whereRef("communities.id", "=", "invites.communityId") + ) + .$notNull() + .as("community"), + jsonObjectFrom( + eb + .selectFrom("pubs") + .select((eb) => [ + "id", + "title", + jsonObjectFrom( + eb + .selectFrom("pub_types") + .select(["id", "name"]) + .whereRef("pub_types.id", "=", "pubs.pubTypeId") + ) + .$notNull() + .as("pubType"), + ]) + .whereRef("pubs.id", "=", "invites.pubId") + ).as("pub"), + jsonObjectFrom( + eb + .selectFrom("stages") + .select(["id", "name"]) + .whereRef("stages.id", "=", "invites.stageId") + ).as("stage"), + jsonObjectFrom( + eb + .selectFrom("users") + .select(SAFE_USER_SELECT) + .whereRef("users.id", "=", "invites.userId") + ) + .$notNull() + .as("user"), + ]) + .executeTakeFirst() as Promise; + } + + /** + * @internal + * Do not use directly + */ + export async function _createInvite(data: NewInvite, trx = db) { + const { + communityFormIds, + pubFormIds, + stageFormIds, + communityFormSlugs, + pubFormSlugs, + stageFormSlugs, + userId, + provisionalUser, + ...restData + } = data; + const communityFormSlugsOrIds = [ + ...(communityFormSlugs ?? []), + ...(communityFormIds ?? []), + ]; + const pubFormSlugsOrIds = [...(pubFormSlugs ?? []), ...(pubFormIds ?? [])]; + const stageFormSlugsOrIds = [...(stageFormSlugs ?? []), ...(stageFormIds ?? [])]; + + const type = + pubFormSlugsOrIds.length > 0 + ? MembershipType.pub + : stageFormSlugsOrIds.length > 0 + ? MembershipType.stage + : communityFormSlugsOrIds.length > 0 + ? MembershipType.community + : null; + + const pubFormIdentifiersAreSlugs = Boolean(pubFormSlugs?.length); + const stageFormIdentifiersAreSlugs = Boolean(stageFormSlugs?.length); + const communityFormIdentifiersAreSlugs = Boolean(communityFormSlugs?.length); + + const toBeInvitedUser = await getUser( + userId ? { id: userId } : { email: expect(provisionalUser).email }, + trx + ).executeTakeFirst(); + let toBeInvitedUserId = toBeInvitedUser?.id; + + if (!toBeInvitedUserId) { + if (userId) { + throw new Error("User not found. No user found with id: " + userId); + } + + const provUser = expect(data.provisionalUser); + expect(provUser); + + const newUser = await addUser( + { + firstName: provUser.firstName, + lastName: provUser.lastName, + email: provUser.email, + slug: generateUserSlug({ + firstName: provUser.firstName, + lastName: provUser.lastName, + }), + isProvisional: true, + }, + trx + ).executeTakeFirstOrThrow(); + + toBeInvitedUserId = newUser.id; + } + + const inviteBase = trx.with("invite", (db) => + db + .insertInto("invites") + .values({ ...restData, userId: toBeInvitedUserId }) + .returningAll() + ); + + const withFormSlugOrId = >( + eb: EB, + identifier: string, + isSlug: boolean + ) => { + if (!isSlug) { + return identifier as FormsId; + } + + return eb + .selectFrom("forms") + .select("id") + .where("slug", "=", identifier) + .where("communityId", "=", data.communityId) + .limit(1); + }; + + const inviteWithForms = type + ? inviteBase.with("invite_forms", (db) => + db + .insertInto("invite_forms") + .values((eb) => [ + ...(pubFormSlugsOrIds?.map((form) => ({ + inviteId: eb + .selectFrom("invite") + .select("id") + .where("token", "=", data.token) + .limit(1), + formId: withFormSlugOrId(eb, form, pubFormIdentifiersAreSlugs), + type: type, + })) ?? []), + ...(stageFormSlugsOrIds?.map((form) => ({ + inviteId: eb + .selectFrom("invite") + .select("id") + .where("token", "=", data.token) + .limit(1), + formId: withFormSlugOrId(eb, form, stageFormIdentifiersAreSlugs), + type: MembershipType.stage, + })) ?? []), + ...(communityFormSlugsOrIds?.map((formId) => ({ + inviteId: eb + .selectFrom("invite") + .select("id") + .where("token", "=", data.token) + .limit(1), + formId: withFormSlugOrId( + eb, + formId, + communityFormIdentifiersAreSlugs + ), + type: MembershipType.community, + })) ?? []), + ]) + .returningAll() + ) + : inviteBase; + + // for type safety this cast is necessary + const inviteFinal = (inviteWithForms as typeof inviteBase) + .selectFrom("invite") + .selectAll() + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom("communities") + .select([ + "communities.id", + "communities.slug", + "communities.avatar", + "communities.name", + ]) + .whereRef("communities.id", "=", "invite.communityId") + ) + .$notNull() + .as("community"), + jsonObjectFrom( + eb + .selectFrom("pubs") + .select((eb) => [ + "pubs.id", + "pubs.title", + jsonObjectFrom( + eb + .selectFrom("pub_types") + .select(["pub_types.id", "pub_types.name"]) + .whereRef("pub_types.id", "=", "pubs.pubTypeId") + ) + .$notNull() + .as("pubType"), + ]) + .whereRef("pubs.id", "=", "invite.pubId") + ).as("pub"), + jsonObjectFrom( + eb + .selectFrom("stages") + .select(["stages.id", "stages.name"]) + .whereRef("stages.id", "=", "invite.stageId") + ).as("stage"), + ]) + .select((eb) => withInvitedFormIds(eb, "invite.id")) + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom("users") + .select(SAFE_USER_SELECT) + .whereRef("users.id", "=", "invite.userId") + ) + .$notNull() + .as("user"), + ]); + + const result = await autoRevalidate(inviteFinal).executeTakeFirstOrThrow(); + + return result as Invite & { user: SafeUser }; + } + + /** + * Sets the status of an invite + * If the status is set to `pending`, it will also set the `lastSentAt` date + */ + export async function setInviteStatus( + invite: Invite, + status: InviteStatus, + lastModifiedBy: LastModifiedBy, + trx = db + ) { + await autoRevalidate( + trx + .updateTable("invites") + .set({ + status, + lastModifiedBy, + lastSentAt: status === InviteStatus.pending ? new Date() : undefined, + }) + .where("id", "=", invite.id) + ).execute(); + } + + /** + * Sets the status of an invite to accepted + * Will check whether the user is allowed to accept the invite: will prevent accepting an invite for another user + * + * Therefore this should never be called as a consequence of a user other than the invitee invoking a server action + * + * @param _user {User?} Can technically also be used to accept an invite on behalf of a user who is just now created. This should be a rarer usecase, only really useful inside of a transaction, hence why it's the last option + * + * @throws {InviteError} If user is not logged in, or if user is not the invitee + */ + export async function completeInvite( + invite: Invite, + trx = db, + _user?: { id: UsersId; email: string } + ) { + const result = await maybeWithTrx(trx, async (trx) => { + const { user } = _user ? { user: _user } : await getLoginData(); + + if (!user) { + throw new InviteError("USER_NOT_LOGGED_IN"); + } + + assertUserIsInvitee(invite, user); + + await setInviteStatus( + invite, + InviteStatus.completed, + createLastModifiedBy({ userId: user.id }), + trx + ); + + await grantInviteMemberships(invite, user, trx); + }); + + return result; + } + + /** + * Sets the status of an invite to reject + * Will check whether the user is allowed to reject the invite: will prevent rejecting an invite for another user + * Although technically anyone can reject an invite if they have it + * + * Therefore this should never be called as a consequence of a user other than the invitee invoking a server action + * + * @throws {InviteError} + */ + export async function rejectInvite(invite: Invite, trx = db) { + const { user } = await getLoginData(); + + if (user) { + assertUserIsInvitee(invite, user); + } + + await trx + .updateTable("invites") + .set({ + status: InviteStatus.rejected, + // system here means that the user with the email rejected the invite + // but they did not sign up yet (ofc) + lastModifiedBy: createLastModifiedBy(user ? { userId: user.id } : "system"), + }) + .where("id", "=", invite.id) + .execute(); + } + + /** + * Revokes an invite for another user + * + * TODO: implement + */ + export async function revokeInvite(token: string, communityId: CommunitiesId, reason?: string) { + throw new Error("Not implemented"); + } + + /** + * @throws {InviteError} + */ + export async function getValidInviteForLoggedInUser(inviteToken: string, trx = db) { + const [invite, { user }] = await Promise.all([ + getValidInvite(inviteToken, trx), + getLoginData(), + ]); + + assertUserIsInvitee(invite, user); + + return { + invite, + user: user!, + }; + } + + /** + * User cannot be invited to a community if they are already a member of the community + */ + export async function canUserBeInvited(userId: UsersId, communityId: CommunitiesId, trx = db) { + const communityMember = await selectCommunityMemberships({ + userId, + communityId, + }).executeTakeFirst(); + + return Boolean(communityMember); + } + + /** + * Checks whether an invite is useless for a user + * + * This means that the invite would not grant any additional roles to the user + * This is only relevant when the user has already accepted another invite in the meantime. + * Currently there are no restrictions on number of open invites per user. + * + * @returns {Promise<{useless: false} | {useless: true, reason: string}>} true if the invite is useless, false otherwise + * @throws {InviteError} if the invite is not pending + */ + export async function isInviteUselessForUser( + invite: Invite, + user: { id: UsersId; email: string }, + trx = db + ): Promise<{ useless: false } | { useless: true; reason: string }> { + assertUserIsInvitee(invite, user); + + if (invite.status !== InviteStatus.pending && invite.status !== InviteStatus.accepted) { + return { + useless: true, + reason: "Invite is not pending or accepted", + }; + } + + const communityMemberships = await selectCommunityMemberships( + { + userId: user.id, + communityId: invite.communityId, + }, + trx + ).execute(); + + if (!communityMemberships?.length) { + return { + useless: false, + }; + } + + const communityMembership = coalesceMemberships(communityMemberships); + + const isCommunityMemberUseless = isInviteUselessForMembership( + { + role: invite.communityRole, + forms: invite.communityFormIds ?? [], + }, + communityMembership + ); + if (isCommunityOnlyInvite(invite)) { + return isCommunityMemberUseless; + } else if (isPubInvite(invite)) { + const pubMemberships = await selectPubMemberships( + { + userId: user.id, + pubId: invite.pubId, + }, + trx + ).execute(); + + if (!pubMemberships?.length) { + return { + useless: false, + }; + } + const pubMember = coalesceMemberships(pubMemberships); + + const isPubMemberUseless = isInviteUselessForMembership( + { + role: invite.pubRole, + forms: invite.pubFormIds ?? [], + }, + pubMember + ); + + return isPubMemberUseless; + } else if (isStageInvite(invite)) { + const stageMemberships = await selectStageMemberships( + { + userId: user.id, + stageId: invite.stageId, + }, + trx + ).execute(); + + if (!stageMemberships?.length) { + return { + useless: false, + }; + } + const stageMember = coalesceMemberships(stageMemberships); + + const stageMemberUseless = isInviteUselessForMembership( + { + role: invite.stageRole, + forms: invite.stageFormIds ?? [], + }, + stageMember + ); + + return stageMemberUseless; + } + + throw new Error("Invalid invite"); + } + + /** + * Adds the user to the community, and possibly the pub or stage, based on the invite data. + * TODO: add form permissions once we have reworked them + */ + export async function grantInviteMemberships( + invite: Invite, + user: { id: UsersId; email: string }, + trx = db + ) { + assertUserIsInvitee(invite, user); + + const res = await maybeWithTrx(trx, async (trx) => { + const isInviteUseless = await isInviteUselessForUser(invite, user, trx); + if (isInviteUseless.useless) { + logger.debug({ + msg: "For some reason, useless invite was accepted", + invite, + user, + isInviteUseless, + }); + return; + } + + // TODO: override lower level of permissions if the user has already accepted another invite + if (isCommunityOnlyInvite(invite)) { + await insertCommunityMembershipsOverrideRole( + { + communityId: invite.communityId, + userId: user.id, + role: invite.communityRole, + forms: invite.communityFormIds ?? [], + }, + trx + ).executeTakeFirstOrThrow(); + } else if (isPubInvite(invite)) { + const communityMember = await insertCommunityMembershipsOverrideRole( + { + communityId: invite.communityId, + userId: user.id, + role: invite.communityRole, + forms: invite.communityFormIds ?? [], + }, + trx + ).executeTakeFirstOrThrow(); + const pubMember = await insertPubMembershipsOverrideRole( + { + pubId: invite.pubId, + userId: user.id, + role: invite.pubRole, + forms: invite.pubFormIds ?? [], + }, + trx + ).executeTakeFirstOrThrow(); + } else if (isStageInvite(invite)) { + await insertCommunityMembershipsOverrideRole( + { + communityId: invite.communityId, + userId: user.id, + role: invite.communityRole, + forms: invite.communityFormIds ?? [], + }, + trx + ).executeTakeFirstOrThrow(); + await insertStageMembershipsOverrideRole( + { + stageId: invite.stageId, + userId: user.id, + role: invite.stageRole, + forms: invite.stageFormIds ?? [], + }, + trx + ).executeTakeFirstOrThrow(); + } + + // TODO: change this as soon as Kalil has implemented the form permissions + }); + } + + /** + * Creates a link to an invite page with an invite token + */ + export async function createInviteLink( + invite: Invite, + options: { + redirectTo: string; + /** + * If true, the url will be absolute + * @default true + */ + absolute?: boolean; + } + ) { + const communitySlug = await getCommunitySlug(); + const inviteToken = createInviteToken(invite); + + const searchParams = new URLSearchParams(); + searchParams.set("invite", inviteToken); + searchParams.set("redirectTo", options.redirectTo); + + return `${options?.absolute === false ? "" : env.PUBPUB_URL}/c/${communitySlug}/public/invite?${searchParams.toString()}`; + } + + export function createInviteToken(invite: Invite) { + return `${invite.id}.${invite.token}`; + } + + export function parseInviteToken(inviteToken: string) { + const [id, token] = inviteToken.split("."); + return { id: id as InvitesId, token }; + } + + /** + * Get an invite, and throw an error if it is not valid + * @throws {InviteError} + */ + export async function getValidInvite(inviteToken: string, trx = db) { + const { id, token } = parseInviteToken(inviteToken); + + const dbInvite = await _getInvite(token, id, trx); + + if (!dbInvite) { + throw new InviteError("NOT_FOUND", { + logContext: { + inviteToken, + }, + status: InviteStatus.created, + }); + } + + const isInvalidInvite = invalidInviteMap[dbInvite.status]; + if (isInvalidInvite) { + throw new InviteError(isInvalidInvite, { + logContext: { + inviteToken, + invite: dbInvite, + }, + status: dbInvite.status, + }); + } + + if ( + !crypto.timingSafeEqual( + new Uint8Array(Buffer.from(dbInvite.token)), + new Uint8Array(Buffer.from(token)) + ) + ) { + throw new InviteError("INVALID_TOKEN", { + logContext: { + inviteToken, + invite: dbInvite, + }, + status: dbInvite.status, + }); + } + + if (dbInvite.expiresAt < new Date()) { + throw new InviteError("EXPIRED", { + logContext: { + inviteToken, + invite: dbInvite, + }, + status: dbInvite.status, + }); + } + + return dbInvite; + } +} + +// helpers, don't need to be part of the service +function isCommunityOnlyInvite(invite: Invite): invite is Invite & { pubId: null; stageId: null } { + return invite.pubId === null && invite.stageId === null; +} + +function isPubInvite(invite: Invite): invite is Invite & { pubId: PubsId } { + return invite.pubId !== null; +} + +function isStageInvite(invite: Invite): invite is Invite & { stageId: StagesId } { + return invite.stageId !== null; +} + +/** + * does the invite grant any new roles or forms + */ +function isInviteUselessForMembership( + invite: { + role: MemberRole; + forms: FormsId[]; + }, + existingMembership: { role: MemberRole; forms: FormsId[] } | undefined +) { + if (!existingMembership) { + return { + useless: false as const, + }; + } + + const hasHigherOrSameRole = compareMemberRoles(existingMembership.role, ">=", invite.role); + + const inviteHasNoNewForms = invite.forms.some( + (formId) => !existingMembership.forms.includes(formId) + ); + + if (hasHigherOrSameRole && inviteHasNoNewForms) { + return { + useless: true as const, + reason: "Invite would not grant additional roles", + }; + } + + return { + useless: false as const, + }; +} diff --git a/core/lib/server/invites/helpers.ts b/core/lib/server/invites/helpers.ts new file mode 100644 index 0000000000..e7d7d8093f --- /dev/null +++ b/core/lib/server/invites/helpers.ts @@ -0,0 +1,30 @@ +import type { ExpressionBuilder } from "kysely"; + +import { sql } from "kysely"; + +import type { FormsId } from "db/public"; +import { MembershipType } from "db/public"; + +export const withInvitedFormIds = >( + eb: EB, + ref: `${string}.${string}` +) => [ + sql`(select coalesce(json_agg("formId"), '[]') from ${eb + .selectFrom("invite_forms") + .where("inviteId", "=", eb.ref(ref)) + .where("invite_forms.type", "=", MembershipType.community) + .select("formId") + .as("communityFormIds")})`.as("communityFormIds"), + sql`(select coalesce(json_agg("formId"), '[]') from ${eb + .selectFrom("invite_forms") + .where("inviteId", "=", eb.ref(ref)) + .where("invite_forms.type", "=", MembershipType.pub) + .select("formId") + .as("pubFormIds")})`.as("pubFormIds"), + sql`(select coalesce(json_agg("formId"), '[]') from ${eb + .selectFrom("invite_forms") + .where("inviteId", "=", eb.ref(ref)) + .where("invite_forms.type", "=", MembershipType.stage) + .select("formId") + .as("stageFormIds")})`.as("stageFormIds"), +]; diff --git a/core/lib/server/member.ts b/core/lib/server/member.ts index 63860aff64..45c057d844 100644 --- a/core/lib/server/member.ts +++ b/core/lib/server/member.ts @@ -1,15 +1,21 @@ +import type { OnConflictBuilder } from "kysely"; + import { jsonObjectFrom } from "kysely/helpers/postgres"; import type { CommunitiesId, CommunityMembershipsId, FormsId, - MemberRole, NewCommunityMemberships, NewPubMemberships, NewStageMemberships, + PubMembershipsId, + PubsId, + StageMembershipsId, + StagesId, UsersId, } from "db/public"; +import { MemberRole } from "db/public"; import type { XOR } from "../types"; import { db } from "~/kysely/database"; @@ -35,6 +41,7 @@ export const selectCommunityMemberships = ( "community_memberships.updatedAt", "community_memberships.role", "community_memberships.communityId", + "community_memberships.formId", jsonObjectFrom( eb .selectFrom("users") @@ -45,7 +52,9 @@ export const selectCommunityMemberships = ( .as("user"), ]) .$if(Boolean(props.userId), (eb) => - eb.where("community_memberships.userId", "=", props.userId!) + eb + .where("community_memberships.userId", "=", props.userId!) + .$narrowType<{ userId: UsersId }>() ) .$if(Boolean(props.communityId), (eb) => eb.where("community_memberships.communityId", "=", props.communityId!) @@ -81,6 +90,76 @@ export const selectAllCommunityMemberships = ( .where("community_memberships.communityId", "=", communityId) ); +export const selectStageMemberships = ( + props: XOR<{ id: StageMembershipsId }, { userId: UsersId; stageId: StagesId }>, + trx = db +) => { + return autoCache( + trx + .selectFrom("stage_memberships") + .select((eb) => [ + "stage_memberships.id", + "stage_memberships.userId", + "stage_memberships.createdAt", + "stage_memberships.updatedAt", + "stage_memberships.role", + "stage_memberships.stageId", + "stage_memberships.formId", + jsonObjectFrom( + eb + .selectFrom("users") + .select(SAFE_USER_SELECT) + .whereRef("users.id", "=", "stage_memberships.userId") + ) + .$notNull() + .as("user"), + ]) + .$if(Boolean(props.userId), (eb) => + eb + .where("stage_memberships.userId", "=", props.userId!) + .$narrowType<{ userId: UsersId }>() + ) + .$if(Boolean(props.stageId), (eb) => + eb.where("stage_memberships.stageId", "=", props.stageId!) + ) + .$if(Boolean(props.id), (eb) => eb.where("stage_memberships.id", "=", props.id!)) + ); +}; + +export const selectPubMemberships = ( + props: XOR<{ id: PubMembershipsId }, { userId: UsersId; pubId: PubsId }>, + trx = db +) => { + return autoCache( + trx + .selectFrom("pub_memberships") + .select((eb) => [ + "pub_memberships.id", + "pub_memberships.userId", + "pub_memberships.createdAt", + "pub_memberships.updatedAt", + "pub_memberships.role", + "pub_memberships.pubId", + "pub_memberships.formId", + jsonObjectFrom( + eb + .selectFrom("users") + .select(SAFE_USER_SELECT) + .whereRef("users.id", "=", "pub_memberships.userId") + ) + .$notNull() + .as("user"), + ]) + .$if(Boolean(props.userId), (eb) => + eb + .where("pub_memberships.userId", "=", props.userId!) + .$narrowType<{ userId: UsersId }>() + ) + .$if(Boolean(props.pubId), (eb) => eb.where("pub_memberships.pubId", "=", props.pubId!)) + .$if(Boolean(props.id), (eb) => eb.where("pub_memberships.id", "=", props.id!)) + ); +}; + const getMembershipRows = ({ forms, ...membership @@ -110,12 +189,108 @@ export const deleteCommunityMemberships = ( .returningAll() ); +export const deleteCommunityMember = (props: CommunityMembershipsId, trx = db) => + autoRevalidate(trx.deleteFrom("community_memberships").where("id", "=", props).returningAll()); + +export const onConflictOverrideRole = ( + oc: OnConflictBuilder, + type: "community" | "pub" | "stage", + withForm: boolean +) => { + return oc + .columns(["userId", `${type}Id`, "formId"]) + .where("formId", withForm ? "is not" : "is", null) + .doUpdateSet((eb) => ({ + role: eb + .case() + .when(eb.ref(`${type}_memberships.role`), "=", MemberRole.admin) + .then(eb.ref(`${type}_memberships.role`)) + .when( + eb.and([ + eb(eb.ref(`${type}_memberships.role`), "=", MemberRole.editor), + eb(eb.ref("excluded.role"), "!=", MemberRole.admin), + ]) + ) + .then(eb.ref(`${type}_memberships.role`)) + .else(eb.ref("excluded.role")) + .end(), + })); +}; + +export const insertCommunityMembershipsOverrideRole = ( + props: NewCommunityMemberships & { userId: UsersId; forms: FormsId[] }, + trx = db +) => + autoRevalidate( + insertCommunityMemberships(props, trx).qb.onConflict((oc) => + onConflictOverrideRole(oc, "community", props.formId !== null) + ) + ); + export const insertStageMemberships = ( membership: NewStageMemberships & { userId: UsersId; forms: FormsId[] }, trx = db ) => autoRevalidate(trx.insertInto("stage_memberships").values(getMembershipRows(membership))); +export const insertStageMembershipsOverrideRole = ( + props: NewStageMemberships & { userId: UsersId; forms: FormsId[] }, + trx = db +) => + autoRevalidate( + insertStageMemberships(props, trx).qb.onConflict((oc) => + onConflictOverrideRole(oc, "stage", props.formId !== null) + ) + ); + +export const insertPubMembershipsOverrideRole = ( + props: NewPubMemberships & { userId: UsersId; forms: FormsId[] }, + trx = db +) => + autoRevalidate( + insertPubMemberships(props, trx).qb.onConflict((oc) => + onConflictOverrideRole(oc, "pub", props.formId !== null) + ) + ); + export const insertPubMemberships = ( membership: NewPubMemberships & { userId: UsersId; forms: FormsId[] }, trx = db ) => autoRevalidate(trx.insertInto("pub_memberships").values(getMembershipRows(membership))); + +export const coalesceMemberships = < + T extends { + role: MemberRole; + formId: FormsId | null; + userId: UsersId | null; + createdAt?: Date | string; + updatedAt?: Date | string; + }, +>( + memberships: T[] +) => { + const { formId, ...firstMembership } = memberships[0]; + + return memberships.reduce( + (acc, { updatedAt, createdAt, formId, ...membership }) => { + let key: keyof typeof membership & string; + // check if all memberships are similar + for (key in membership) { + if (membership[key] !== acc[key as keyof typeof acc]) { + throw new Error( + `Membership ${key} mismatch between ${membership[key]} and ${acc[key as keyof typeof acc]}` + ); + } + } + + if (formId) { + acc.forms.push(formId); + } + + return acc; + }, + { + ...firstMembership, + forms: formId ? [formId] : [], + } as Omit & { forms: FormsId[] } + ); +}; diff --git a/core/lib/server/navigation/redirects.ts b/core/lib/server/navigation/redirects.ts new file mode 100644 index 0000000000..28eba9453f --- /dev/null +++ b/core/lib/server/navigation/redirects.ts @@ -0,0 +1,114 @@ +import { redirect } from "next/navigation"; +import { getPathname } from "@nimpl/getters/get-pathname"; + +import type { NoticeParams } from "~/app/components/Notice"; +import { getCommunitySlug } from "../cache/getCommunitySlug"; + +const defaultLoginRedirectError = { + type: "error", + title: "You must be logged in to access this page", + body: "Please log in to continue", +}; + +export type LoginRedirectOpts = { + /** + * Provide some notice to display on the login page + * An `error` will be red, a `notice` will be neutral. + * + * Set to `false` for no notice. + * + * @default { type: "error", title: "You must be logged in to access this page", body: "Please log in to continue" } + */ + loginNotice?: NoticeParams | false; + + /** + * Path to redirect the user to after login. + * Needs to be of the form `/c//path` + * + * @default currentPathname + */ + redirectTo?: string; +}; + +export const constructLoginLink = (opts?: LoginRedirectOpts) => { + const searchParams = new URLSearchParams(); + + if (opts?.loginNotice !== false) { + const notice = opts?.loginNotice ?? defaultLoginRedirectError; + searchParams.set(notice.type, notice.title); + if (notice.body) { + searchParams.set("body", notice.body); + } + } + + const redirectTo = opts?.redirectTo ?? getPathname(); + if (redirectTo) { + searchParams.set("redirectTo", redirectTo); + } + + const basePath = `/login?${searchParams.toString()}`; + return basePath; +}; + +/** + * Redirect the user to the login page, with a notice to display. + */ +export function redirectToLogin(opts?: LoginRedirectOpts): never { + const basePath = constructLoginLink(opts); + redirect(basePath); +} + +export const constructCommunitySignupLink = async (opts: { + redirectTo: string; + notice?: NoticeParams; + inviteToken?: string; +}) => { + const communitySlug = await getCommunitySlug(); + + const searchParams = new URLSearchParams(); + + searchParams.set("redirectTo", opts.redirectTo); + + if (opts.notice) { + searchParams.set(opts.notice.type, opts.notice.title); + if (opts.notice.body) { + searchParams.set("body", opts.notice.body); + } + } + + if (opts.inviteToken) { + searchParams.set("inviteToken", opts.inviteToken); + } + + const basePath = `/c/${communitySlug}/public/signup?${searchParams.toString()}`; + return basePath; +}; + +/** + * Redirect the user to the signup page, optionally with a notice. + * + * Notice will provide a notice at the top of the signup page + * NOTE: you need to be inside a community to use this + */ +export async function redirectToCommunitySignup(opts: { + redirectTo: string; + notice?: NoticeParams; + inviteToken?: string; +}): Promise { + const basePath = await constructCommunitySignupLink(opts); + redirect(basePath); +} + +export const constructVerifyLink = (opts: { redirectTo: string }) => { + const searchParams = new URLSearchParams(); + + searchParams.set("redirectTo", opts.redirectTo); + + const basePath = `/verify?${searchParams.toString()}`; + return basePath; +}; + +export function redirectToVerify(opts: { redirectTo: string }): never { + const basePath = constructVerifyLink(opts); + redirect(basePath); +} diff --git a/core/lib/server/render/pub/renderMarkdownWithPub.ts b/core/lib/server/render/pub/renderMarkdownWithPub.ts index 4284992a29..a1d51f3042 100644 --- a/core/lib/server/render/pub/renderMarkdownWithPub.ts +++ b/core/lib/server/render/pub/renderMarkdownWithPub.ts @@ -89,7 +89,7 @@ const visitRecipientNameDirective = (node: Directive, context: utils.RenderWithP hChildren: [ { type: "text", - value: utils.renderRecipientFullName(context), + value: utils.renderRecipientFullName(context, node.name), }, ], }; @@ -102,7 +102,7 @@ const visitRecipientFirstNameDirective = (node: Directive, context: utils.Render hChildren: [ { type: "text", - value: utils.renderRecipientFirstName(context), + value: utils.renderRecipientFirstName(context, node.name), }, ], }; @@ -115,7 +115,7 @@ const visitRecipientLastNameDirective = (node: Directive, context: utils.RenderW hChildren: [ { type: "text", - value: utils.renderRecipientLastName(context), + value: utils.renderRecipientLastName(context, node.name), }, ], }; @@ -217,7 +217,10 @@ const getDirectiveVisitor = (node: Directive) => { ) { return visitValueDirectiveWithMemberField; } - return expect(directiveVisitors[directiveName], "Invalid directive used in markdown template."); + return expect( + directiveVisitors[directiveName], + `Invalid directive ${directiveName} used in markdown template.` + ); }; const renderMarkdownWithPubPlugin: Plugin<[utils.RenderWithPubContext]> = (context) => { @@ -258,12 +261,16 @@ const renderMarkdownWithPubPlugin: Plugin<[utils.RenderWithPubContext]> = (conte if (isDirective(node)) { const attrs = expect(node.attributes); if ("form" in attrs) { - props.href = await utils.renderFormInviteLink({ - formSlug: expect(attrs.form), - userId: expect(context.recipient, ERR_FORM_MISSING_RECIPIENT).user.id, - communityId: context.communityId, - pubId: context.pub.id as PubsId, - }); + props.href = await utils.renderFormInviteLink( + { + formSlug: expect(attrs.form), + recipient: expect(context.recipient, ERR_FORM_MISSING_RECIPIENT), + communityId: context.communityId, + pubId: context.pub.id as PubsId, + inviter: expect(context.inviter), + }, + context.trx + ); assert(isParent(node)); // Include default text node.children = [ diff --git a/core/lib/server/render/pub/renderWithPubUtils.ts b/core/lib/server/render/pub/renderWithPubUtils.ts index a9678b91e1..fb9a4c9b15 100644 --- a/core/lib/server/render/pub/renderWithPubUtils.ts +++ b/core/lib/server/render/pub/renderWithPubUtils.ts @@ -1,11 +1,24 @@ -import type { CommunitiesId, CommunityMembershipsId, PubsId, UsersId } from "db/public"; -import { CoreSchemaType } from "db/public"; -import { expect } from "utils"; - +import type { Kysely, Transaction } from "kysely"; + +import type { Database } from "db/Database"; +import type { + ActionRunsId, + CommunitiesId, + CommunityMembershipsId, + PubsId, + UsersId, +} from "db/public"; +import { CoreSchemaType, InviteStatus, MemberRole } from "db/public"; +import { assert, expect } from "utils"; + +import type { XOR } from "~/lib/types"; import { db } from "~/kysely/database"; import { env } from "~/lib/env/env"; +import { createLastModifiedBy } from "~/lib/lastModifiedBy"; import { autoCache } from "~/lib/server/cache/autoCache"; +import { getCommunitySlug } from "../../cache/getCommunitySlug"; import { createFormInviteLink, grantFormAccess } from "../../form"; +import { InviteService } from "../../invites/InviteService"; export type RenderWithPubRel = "self"; @@ -24,19 +37,26 @@ export type RenderWithPubPub = { }; }; +export type Recipient = + | { + id: CommunityMembershipsId; + user: { + id: UsersId; + firstName: string; + lastName: string | null; + email: string; + }; + email?: never; + } + | { email: string; user?: never; id?: never }; + export type RenderWithPubContext = { - recipient?: { - id: CommunityMembershipsId; - user: { - id: UsersId; - firstName: string; - lastName: string | null; - email: string; - }; - }; + recipient?: Recipient; communityId: CommunitiesId; communitySlug: string; pub: RenderWithPubPub; + inviter?: XOR<{ userId: UsersId }, { actionRunId: ActionRunsId }>; + trx: Kysely; }; export const ALLOWED_MEMBER_ATTRIBUTES = ["firstName", "lastName", "email"] as const; @@ -51,19 +71,67 @@ const getPubValue = (context: RenderWithPubContext, fieldSlug: string, rel?: str return expect(pubValue, `Expected pub to have value for field "${fieldSlug}"`); }; -export const renderFormInviteLink = async ({ - formSlug, - userId, - communityId, - pubId, -}: { - formSlug: string; - userId: UsersId; - communityId: CommunitiesId; - pubId: PubsId; -}) => { - await grantFormAccess({ userId, communityId, pubId, slug: formSlug }); - return createFormInviteLink({ userId, formSlug, communityId, pubId }); +export const renderFormInviteLink = async ( + { + formSlug, + recipient, + communityId, + pubId, + inviter, + }: { + formSlug: string; + recipient: Recipient; + communityId: CommunitiesId; + pubId: PubsId; + inviter: XOR<{ userId: UsersId }, { actionRunId: ActionRunsId }>; + }, + trx = db +) => { + // this feels weird to do here + if (recipient.id) { + await grantFormAccess({ userId: recipient.user.id, communityId, pubId, slug: formSlug }); + return createFormInviteLink( + { userId: recipient.user.id, formSlug, communityId, pubId }, + trx + ); + } + + const baseInvite = InviteService.inviteUser({ + email: recipient.email!, + firstName: "", + lastName: "", + }); + const inviteWithInviter = baseInvite.invitedBy( + inviter.userId ? { userId: inviter.userId } : { actionRunId: inviter.actionRunId! } + ); + + const communitySlug = await getCommunitySlug(); + + const invite = await inviteWithInviter + .forCommunity(communityId) + .withRole(MemberRole.contributor) + .withForms([formSlug]) + .forPub(pubId) + .withRole(MemberRole.contributor) + .withForms([formSlug]) + .expiresInDays(30) + .create(trx); + + const inviteLink = await InviteService.createInviteLink(invite, { + redirectTo: `/c/${communitySlug}/public/forms/${formSlug}/fill?pubId=${pubId}`, + }); + + await InviteService.setInviteStatus( + invite, + InviteStatus.pending, + createLastModifiedBy({ + userId: inviter.userId, + actionRunId: inviter.actionRunId, + }), + trx + ); + + return inviteLink; }; export const renderMemberFields = async ({ @@ -205,19 +273,31 @@ export const renderLink = (context: RenderWithPubContext, options: LinkOptions) return href; }; -export const renderRecipientFirstName = (context: RenderWithPubContext) => { - return expect(context.recipient, "Used a recipient token without specifying a recipient").user - .firstName; -}; +export const contextWithUserRecipient = (context: RenderWithPubContext, token: string) => { + assert(context.recipient, `Used a recipient token "${token}" without specifying a recipient`); -export const renderRecipientLastName = (context: RenderWithPubContext) => { - return ( - expect(context.recipient, "Used a recipient token without specifying a recipient").user - .lastName ?? "" + assert( + "user" in context.recipient, + `Used a recipient token "${token}" without a user recipient` ); + + return context as typeof context & { + recipient: { + id: CommunityMembershipsId; + user: NonNullable["user"]; + }; + }; +}; + +export const renderRecipientFirstName = (context: RenderWithPubContext, token: string) => { + return contextWithUserRecipient(context, token).recipient.user.firstName; +}; + +export const renderRecipientLastName = (context: RenderWithPubContext, token: string) => { + return contextWithUserRecipient(context, token).recipient.user.lastName ?? ""; }; -export const renderRecipientFullName = (context: RenderWithPubContext) => { - const lastName = renderRecipientLastName(context); - return `${renderRecipientFirstName(context)}${lastName && ` ${lastName}`}`; +export const renderRecipientFullName = (context: RenderWithPubContext, token: string) => { + const lastName = renderRecipientLastName(context, token); + return `${renderRecipientFirstName(context, token)}${lastName && ` ${lastName}`}`; }; diff --git a/core/lib/server/user.ts b/core/lib/server/user.ts index f7f31aeb6c..6c271f941c 100644 --- a/core/lib/server/user.ts +++ b/core/lib/server/user.ts @@ -21,15 +21,15 @@ import { Capabilities, FormAccessType, MemberRole, MembershipType } from "db/pub import type { CapabilityTarget } from "../authorization/capabilities"; import type { XOR } from "../types"; import { db } from "~/kysely/database"; +import { compareMemberRoles, getHighestRole } from "~/lib/authorization/rolesRanking"; import { getLoginData } from "../authentication/loginData"; import { createPasswordHash } from "../authentication/password"; import { userCan } from "../authorization/capabilities"; -import { firstRoleIsHigher, getHighestRole } from "../authorization/rolesRanking"; import { generateHash, slugifyString } from "../string"; import { autoCache } from "./cache/autoCache"; import { autoRevalidate } from "./cache/autoRevalidate"; import { findCommunityBySlug } from "./community"; -import { signupInvite } from "./email"; +import { _legacy_signupInvite } from "./email"; import { insertCommunityMemberships, insertPubMemberships, insertStageMemberships } from "./member"; import { getPubTitle } from "./pub"; @@ -46,6 +46,7 @@ export const SAFE_USER_SELECT = [ "users.avatar", "users.orcid", "users.isVerified", + "users.isProvisional", ] as const satisfies ReadonlyArray>; export const getUser = cache((userIdOrEmail: XOR<{ id: UsersId }, { email: string }>, trx = db) => { @@ -161,16 +162,14 @@ export const getSuggestedUsers = ({ ) .limit(limit); -export const setUserPassword = cache( - async (props: { userId: UsersId; password: string }, trx = db) => { - const passwordHash = await createPasswordHash(props.password); - await trx - .updateTable("users") - .set({ passwordHash }) - .where("id", "=", props.userId) - .execute(); - } -); +export const setUserPassword = async (props: { userId: UsersId; password: string }, trx = db) => { + const passwordHash = await createPasswordHash(props.password); + await trx + .updateTable("users") + .set({ passwordHash }) + .where("id", "=", props.userId) + .executeTakeFirstOrThrow(); +}; export const updateUser = async ( props: Omit & { id: UsersId }, @@ -265,7 +264,8 @@ export const createUserWithMemberships = async (data: { user.memberships.filter((m) => m.communityId === community.id) ); - const roleIsHighEnough = highestRole && firstRoleIsHigher(highestRole, membership.role); + const roleIsHighEnough = + highestRole && compareMemberRoles(highestRole, ">=", membership.role); if (!roleIsHighEnough) { return { @@ -366,7 +366,7 @@ export const createUserWithMemberships = async (data: { membershipQuery(trx, newUser.id), ]); } - const result = await signupInvite( + const result = await _legacy_signupInvite( { user: newUser, community, diff --git a/core/lib/types.ts b/core/lib/types.ts index d67428a5e0..530b3f4bed 100644 --- a/core/lib/types.ts +++ b/core/lib/types.ts @@ -25,7 +25,7 @@ export type MemberWithUser = Omit & { export type UserPostBody = Pick; export type UserPutBody = Pick; -export type UserLoginData = Omit; +export type UserLoginData = Omit; export type UserSetting = Pick & { communities: Communities[]; }; diff --git a/core/playwright/externalFormCreatePub.spec.ts b/core/playwright/externalFormCreatePub.spec.ts index 2ccee5e46e..cbc8612b87 100644 --- a/core/playwright/externalFormCreatePub.spec.ts +++ b/core/playwright/externalFormCreatePub.spec.ts @@ -40,6 +40,10 @@ const seed = createSeed({ role: MemberRole.contributor, password: "xxxx-xxxx", }, + user3: { + role: MemberRole.contributor, + password: "xxxx-xxxx", + }, }, pubTypes: { Submission: { @@ -291,12 +295,6 @@ test.describe("Rich text editor", () => { test.describe("Member select", async () => { test("Can select a member", async () => { - // Add a member (all@pubpub.org is the only member by default) - const member1 = "all@pubpub.org"; - const membersPage = new MembersPage(page, community.community.slug); - await membersPage.goto(); - const { email: member2 } = await membersPage.addNewUser(faker.internet.email()); - const fieldsPage = new FieldsPage(page, community.community.slug); await fieldsPage.goto(); await fieldsPage.addField("member", CoreSchemaType.MemberId); @@ -326,15 +324,15 @@ test.describe("Member select", async () => { const title = "member test"; await page.getByTestId(`${community.community.slug}:title`).fill(title); await page.getByTestId(`${community.community.slug}:content`).fill("content"); - await memberInput.fill(member1); - await page.getByLabel(member1).click(); - await expect(memberInput).toHaveValue(member1); + await memberInput.fill(community.users.user2.email); + await page.getByLabel(community.users.user2.email).click(); + await expect(memberInput).toHaveValue(community.users.user2.email); // Switch to a different user await memberInput.clear(); - await memberInput.pressSequentially(member2); - await page.getByLabel(member2).click(); - await expect(memberInput).toHaveValue(member2); + await memberInput.pressSequentially(community.users.user3.email); + await page.getByLabel(community.users.user3.email).click(); + await expect(memberInput).toHaveValue(community.users.user3.email); // Add a new user const newUser = faker.internet.email(); diff --git a/core/playwright/externalFormInvite.spec.ts b/core/playwright/externalFormInvite.spec.ts index f3244e2503..2430da690a 100644 --- a/core/playwright/externalFormInvite.spec.ts +++ b/core/playwright/externalFormInvite.spec.ts @@ -3,7 +3,14 @@ import type { Page } from "@playwright/test"; import { faker } from "@faker-js/faker"; import { expect, test } from "@playwright/test"; -import { Action, CoreSchemaType, ElementType, InputComponent, MemberRole } from "db/public"; +import { + Action, + CoreSchemaType, + ElementType, + InputComponent, + InviteStatus, + MemberRole, +} from "db/public"; import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; import { createSeed } from "~/prisma/seed/createSeed"; @@ -14,10 +21,18 @@ import { PubDetailsPage } from "./fixtures/pub-details-page"; import { PubsPage } from "./fixtures/pubs-page"; import { inbucketClient } from "./helpers"; -const ACTION_NAME = "Invite evaluator"; -const firstName = faker.person.firstName(); -const lastName = faker.person.lastName(); -const email = `${firstName}@example.com`; +const ACTION_NAME_USER = "Invite evaluator (user)"; +const ACTION_NAME_EMAIL = "Invite evaluator (email)"; + +const firstName1 = faker.person.firstName(); +const lastName1 = faker.person.lastName(); +const email1 = `${firstName1}@example.com`; + +const firstName2 = faker.person.firstName(); +const lastName2 = faker.person.lastName(); +const email2 = `${firstName2}@example.com`; + +const evalSlug = "evaluation"; test.describe.configure({ mode: "serial" }); @@ -63,13 +78,20 @@ const seed = createSeed({ stages: { Evaluating: { actions: { - [ACTION_NAME]: { + [ACTION_NAME_USER]: { action: Action.email, - name: ACTION_NAME, config: { subject: "Hello", body: "Greetings", - recipientEmail: "test@example.com", + recipientEmail: email1, + }, + }, + [ACTION_NAME_EMAIL]: { + action: Action.email, + config: { + subject: "HELLO REVIEW OUR STUFF PLEASE... privately", + recipientEmail: email2, + body: `You are invited to fill in a form.\n\n\n\n:link{form="${evalSlug}" text="Wow, a great form!"}\n\n`, }, }, }, @@ -90,10 +112,15 @@ const seed = createSeed({ }, stage: "Evaluating", }, + { + pubType: "Evaluation", + values: {}, + stage: "Evaluating", + }, ], forms: { Evaluation: { - slug: "evaluation", + slug: evalSlug, pubType: "Evaluation", elements: [ { @@ -123,6 +150,19 @@ const seed = createSeed({ ], }, }, + invites: { + happyEmailInvite: { + provisionalUser: { + email: email2, + firstName: firstName2, + lastName: lastName2, + }, + communityFormSlugs: ["Evaluation"], + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + }, + }, }); let community: CommunitySeedOutput; @@ -151,9 +191,9 @@ test.describe("Inviting a new user to fill out a form", () => { community.pubs[0].id ); await pubDetailsPage.goTo(); - await pubDetailsPage.runAction(ACTION_NAME, async (runActionDialog) => { + await pubDetailsPage.runAction(ACTION_NAME_USER, async (runActionDialog) => { // Invite a new user to fill out the form - await runActionDialog.getByRole("combobox").fill(email); + await runActionDialog.getByRole("combobox").fill(email1); const memberDialog = runActionDialog.getByRole("listbox", { name: "Suggestions", @@ -166,14 +206,14 @@ test.describe("Inviting a new user to fill out a form", () => { }) .click(); - await memberDialog.getByLabel("First Name").fill(firstName); - await memberDialog.getByLabel("Last Name").fill(lastName); + await memberDialog.getByLabel("First Name").fill(firstName1); + await memberDialog.getByLabel("Last Name").fill(lastName1); // TODO: figure out how to remove this timeout without making the test flaky await page.waitForTimeout(2000); await memberDialog.getByRole("button", { name: "Submit", exact: true }).click(); await memberDialog .getByRole("option", { - name: email, + name: email1, exact: true, }) .click(); @@ -190,7 +230,7 @@ test.describe("Inviting a new user to fill out a form", () => { }); // fails with large number of pubs in the db test("New user can fill out the form from the email link", async ({ browser }) => { - const { message } = await (await inbucketClient.getMailbox(firstName)).getLatestMessage(); + const { message } = await (await inbucketClient.getMailbox(firstName1)).getLatestMessage(); const url = message.body.html?.match(/a href="([^"]+)"/)?.[1]; expect(url).toBeTruthy(); @@ -239,8 +279,8 @@ test.describe("Inviting a new user to fill out a form", () => { // which should let them access the create pub form const membersPage = new MembersPage(page, community.community.slug); await membersPage.goto(); - await membersPage.removeMember(email); - await membersPage.addExistingUser(email, MemberRole.contributor, [ + await membersPage.removeMember(email1); + await membersPage.addExistingUser(email1, MemberRole.contributor, [ community.forms.Evaluation.name, ]); @@ -255,4 +295,37 @@ test.describe("Inviting a new user to fill out a form", () => { // Expect 404 page await expect(newPage.getByText("This page could not be found.")).toHaveCount(1); }); + + // happy path + test("Invites without creating a new user", async () => { + await test.step("admin sends invite to non-existing user", async () => { + const pubDetailsPage = new PubDetailsPage( + page, + community.community.slug, + community.pubs[2].id + ); + await pubDetailsPage.goTo(); + + await pubDetailsPage.runAction(ACTION_NAME_EMAIL); + }); + + await test.step("user clicks link in email", async () => { + const { message } = await ( + await inbucketClient.getMailbox(firstName2) + ).getLatestMessage(); + const url = message.body.html?.match(/a href="([^"]+)"/)?.[1]; + expect(url).toBeTruthy(); + + // Use the browser to decode the html entities in our URL + const decodedUrl = await page.evaluate((url) => { + const elem = document.createElement("div"); + elem.innerHTML = url; + return elem.textContent!; + }, url!); + + await page.goto(decodedUrl); + + await page.waitForURL(/\/invite/); + }); + }); }); diff --git a/core/playwright/externalFormWithPubId.spec.ts b/core/playwright/externalFormWithPubId.spec.ts index afd519eba8..5d4c37e4f2 100644 --- a/core/playwright/externalFormWithPubId.spec.ts +++ b/core/playwright/externalFormWithPubId.spec.ts @@ -1,22 +1,33 @@ import type { Page } from "@playwright/test"; +import { faker } from "@faker-js/faker"; import { expect, test } from "@playwright/test"; -import { CoreSchemaType, ElementType, InputComponent } from "db/public"; +import { CoreSchemaType, ElementType, InputComponent, MemberRole } from "db/public"; import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; import { createSeed } from "~/prisma/seed/createSeed"; import { seedCommunity } from "~/prisma/seed/seedCommunity"; import { LoginPage } from "./fixtures/login-page"; import { PubDetailsPage } from "./fixtures/pub-details-page"; -import { createBaseSeed, PubFieldsOfEachType } from "./helpers"; +import { PubFieldsOfEachType } from "./helpers"; test.describe.configure({ mode: "serial" }); let page: Page; const seed = createSeed({ - ...createBaseSeed(), + community: { + name: "Test Community", + slug: "test-community-x", + }, + users: { + admin: { + email: faker.internet.email(), + role: MemberRole.admin, + password: "password", + }, + }, pubFields: { Title: { schemaName: CoreSchemaType.String, diff --git a/core/playwright/invites.spec.ts b/core/playwright/invites.spec.ts new file mode 100644 index 0000000000..4896f6320b --- /dev/null +++ b/core/playwright/invites.spec.ts @@ -0,0 +1,886 @@ +import crypto from "crypto"; + +import type { Page } from "@playwright/test"; + +import { faker } from "@faker-js/faker"; +import { expect, test } from "@playwright/test"; + +import type { PubsId, UsersId } from "db/public"; +import { + Action, + CoreSchemaType, + ElementType, + InputComponent, + InviteStatus, + MemberRole, +} from "db/public"; + +import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; +import { createSeed } from "~/prisma/seed/createSeed"; +import { seedCommunity } from "~/prisma/seed/seedCommunity"; +import { FieldsPage } from "./fixtures/fields-page"; +import { LoginPage } from "./fixtures/login-page"; + +const ACTION_NAME_USER = "Invite evaluator (user)"; +const ACTION_NAME_EMAIL = "Invite evaluator (email)"; + +const firstName1 = faker.person.firstName(); +const lastName1 = faker.person.lastName(); +const email1 = `${firstName1}@example.com`; + +const firstName2 = faker.person.firstName(); +const lastName2 = faker.person.lastName(); +const email2 = `${firstName2}@example.com`; + +const firstName3 = faker.person.firstName(); +const lastName3 = faker.person.lastName(); +const email3 = `${firstName3}@example.com`; + +const firstName4 = faker.person.firstName(); +const lastName4 = faker.person.lastName(); +const email4 = `${firstName4}@example.com`; + +const firstName5 = faker.person.firstName(); +const lastName5 = faker.person.lastName(); +const email5 = `${firstName5}@example.com`; + +const firstName6 = faker.person.firstName(); +const lastName6 = faker.person.lastName(); +const email6 = `${firstName6}@example.com`; + +const firstName7 = faker.person.firstName(); +const lastName7 = faker.person.lastName(); +const email7 = `${firstName7}@example.com`; + +const evalSlug = "evaluation"; +const communityFormSlug = "community-form"; + +test.describe.configure({ mode: "serial" }); + +let page: Page; + +const invitedUserId = crypto.randomUUID() as UsersId; +const completedInviteUserId = crypto.randomUUID() as UsersId; + +const pub1Id = crypto.randomUUID() as PubsId; + +const seed = createSeed({ + community: { + name: `test community`, + slug: "test-community", + }, + pubFields: { + Title: { + schemaName: CoreSchemaType.String, + }, + Content: { + schemaName: CoreSchemaType.String, + }, + Email: { + schemaName: CoreSchemaType.Email, + }, + }, + users: { + admin: { + role: MemberRole.admin, + password: "password", + }, + user2: { + role: MemberRole.contributor, + password: "xxxx-xxxx", + }, + completedInviteUser: { + id: completedInviteUserId, + role: MemberRole.contributor, + password: "password", + }, + }, + pubTypes: { + Submission: { + Title: { isTitle: true }, + Content: { isTitle: false }, + }, + Evaluation: { + Title: { isTitle: true }, + Content: { isTitle: false }, + Email: { isTitle: false }, + }, + }, + stages: { + Evaluating: { + actions: { + [ACTION_NAME_USER]: { + action: Action.email, + config: { + subject: "Hello", + body: "Greetings", + recipientEmail: email1, + }, + }, + [ACTION_NAME_EMAIL]: { + action: Action.email, + config: { + subject: "HELLO REVIEW OUR STUFF PLEASE... privately", + recipientEmail: email2, + body: `You are invited to fill in a form.\n\n\n\n:link{form="${evalSlug}" text="Wow, a great form!"}\n\n`, + }, + }, + }, + }, + }, + pubs: [ + { + id: pub1Id, + pubType: "Submission", + values: { + Title: "The Activity of Snails", + }, + stage: "Evaluating", + }, + { + pubType: "Submission", + values: { + Title: "Do not let anyone edit me", + }, + stage: "Evaluating", + }, + { + pubType: "Evaluation", + values: {}, + stage: "Evaluating", + }, + ], + forms: { + Evaluation: { + slug: evalSlug, + pubType: "Evaluation", + elements: [ + { + type: ElementType.pubfield, + field: "Title", + component: InputComponent.textInput, + config: { + label: "Title", + }, + }, + { + type: ElementType.pubfield, + field: "Content", + component: InputComponent.textArea, + config: { + label: "Content", + }, + }, + { + type: ElementType.pubfield, + field: "Email", + component: InputComponent.textInput, + config: { + label: "Email", + }, + }, + ], + }, + CommunityForm: { + slug: communityFormSlug, + pubType: "Submission", + elements: [ + { + type: ElementType.pubfield, + field: "Title", + component: InputComponent.textInput, + config: { + label: "Form Title", + }, + }, + ], + }, + }, + invites: { + expiredEmailInvite: { + provisionalUser: { + email: email1, + firstName: firstName1, + lastName: lastName1, + }, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() - 1000 * 60 * 60 * 24), + }, + completedEmailInvite: { + provisionalUser: { + email: email3, + firstName: firstName3, + lastName: lastName3, + }, + status: InviteStatus.completed, + lastSentAt: new Date(), + }, + acceptedYetNotCompletedUserInvite: { + provisionalUser: { + email: email7, + firstName: firstName7, + lastName: lastName7, + }, + status: InviteStatus.accepted, + communityFormSlugs: ["Evaluation"], + lastSentAt: new Date(), + }, + rejectedEmailInvite: { + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }, + status: InviteStatus.rejected, + lastSentAt: new Date(), + }, + revokedEmailInvite: { + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }, + status: InviteStatus.revoked, + lastSentAt: new Date(), + }, + createdEmailInvite: { + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }, + status: InviteStatus.created, + }, + happyPathEmailInvite: { + provisionalUser: { + email: email2, + firstName: firstName2, + lastName: lastName2, + }, + communityFormSlugs: ["Evaluation"], + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + }, + expiredUserInvite: { + userId: invitedUserId, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() - 1000 * 60 * 60 * 24), + }, + completedUserInvite: { + userId: completedInviteUserId, + status: InviteStatus.completed, + lastSentAt: new Date(), + }, + rejectedUserInvite: { + userId: invitedUserId, + status: InviteStatus.rejected, + lastSentAt: new Date(), + }, + revokedUserInvite: { + userId: invitedUserId, + status: InviteStatus.revoked, + lastSentAt: new Date(), + }, + createdUserInvite: { + userId: invitedUserId, + status: InviteStatus.created, + }, + happyPathUserInvite: { + userId: invitedUserId, + communityFormSlugs: ["Evaluation"], + status: InviteStatus.pending, + lastSentAt: new Date(), + }, + rejectEmailInvite: { + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: firstName2, + lastName: lastName2, + }, + pubId: pub1Id, + pubFormSlugs: ["Evaluation"], + pubRole: MemberRole.contributor, + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + + pubLevelFormInvite: { + provisionalUser: { + email: email4, + firstName: firstName4, + lastName: lastName4, + }, + pubId: pub1Id, + pubFormSlugs: ["CommunityForm"], + pubRole: MemberRole.contributor, + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + adminRoleInvite: { + provisionalUser: { + email: email5, + firstName: firstName5, + lastName: lastName5, + }, + communityRole: MemberRole.admin, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + multipleInvite1: { + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }, + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + multipleInvite2: { + provisionalUser: { + email: email6, + firstName: firstName6, + lastName: lastName6, + }, + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + nearlyExpiredInvite: { + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: firstName1, + lastName: lastName1, + }, + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 10), + }, + }, +}); + +const seed2 = createSeed({ + community: { + name: `test community 2`, + slug: "test-community-2", + }, + users: { + invitee1: { + id: invitedUserId, + role: MemberRole.admin, + password: "password", + }, + }, +}); + +let community: CommunitySeedOutput; +let community2: CommunitySeedOutput; +let inviteBasePath: string; + +test.beforeAll(async ({ browser }) => { + // needs to be first bc invites in community1 refer to users in community2 + community2 = await seedCommunity(seed2); + community = await seedCommunity(seed); + page = await browser.newPage(); + + inviteBasePath = `/c/${community.community.slug}/public/invite`; +}); + +const createInviteUrl = (inviteToken: string, redirectTo: string) => { + const inviteUrl = `${inviteBasePath}?invite=${inviteToken}&redirectTo=${redirectTo}`; + return inviteUrl; +}; + +test.afterAll(async () => { + await page?.close(); +}); + +const expectInvalidInvite = (inviteToken: string, page: Page) => { + return { + toShow: async (text: string) => { + const redirectTo = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; + const inviteUrl = createInviteUrl(inviteToken, redirectTo); + + await page.goto(inviteUrl); + await expect(page).toHaveURL(inviteUrl); + + expect(page.getByText(text)).toBeVisible({ + timeout: 2_000, + }); + }, + }; +}; + +test.describe("invalid invite scenarios", () => { + test("no invite token provided", async () => { + await page.goto(inviteBasePath); + await expect(page).toHaveURL(inviteBasePath); + + await expect(page.getByText("no Invite Found")).toBeVisible({ + timeout: 2_000, + }); + + await expect(page.getByText("no invite was provided.")).toBeVisible(); + }); + + test.describe("email invites", () => { + test("expired invite shows appropriate message", async ({ page }) => { + await expectInvalidInvite( + community.invites.expiredEmailInvite.inviteToken, + page + ).toShow("This invite has expired."); + }); + + test("already completed invite shows success message", async ({ page }) => { + await expectInvalidInvite( + community.invites.completedEmailInvite.inviteToken, + page + ).toShow("This invite has already been completed."); + }); + + test("rejected invite shows appropriate message", async ({ page }) => { + await expectInvalidInvite( + community.invites.rejectedEmailInvite.inviteToken, + page + ).toShow("You have already rejected this invite."); + }); + + test("revoked invite shows appropriate message", async ({ page }) => { + await expectInvalidInvite( + community.invites.revokedEmailInvite.inviteToken, + page + ).toShow("This invite has been revoked."); + }); + + test("created but not sent invite shows appropriate message", async ({ page }) => { + await expectInvalidInvite( + community.invites.createdEmailInvite.inviteToken, + page + ).toShow("This invite is not ready for use."); + }); + }); + + test.describe("user invites", () => { + test("expired invite shows appropriate message", async ({ page }) => { + await expectInvalidInvite(community.invites.expiredUserInvite.inviteToken, page).toShow( + "This invite has expired." + ); + }); + + test("already accepted invite shows success message", async ({ page }) => { + await expectInvalidInvite( + community.invites.completedUserInvite.inviteToken, + page + ).toShow("This invite has already been completed."); + }); + + test("rejected invite shows appropriate message", async ({ page }) => { + await expectInvalidInvite( + community.invites.rejectedUserInvite.inviteToken, + page + ).toShow("You have already rejected this invite."); + }); + + test("revoked invite shows appropriate message", async ({ page }) => { + await expectInvalidInvite(community.invites.revokedUserInvite.inviteToken, page).toShow( + "This invite has been revoked." + ); + }); + + test("created but not sent invite shows appropriate message", async ({ page }) => { + await expectInvalidInvite(community.invites.createdUserInvite.inviteToken, page).toShow( + "This invite is not ready for use." + ); + }); + }); +}); + +test.describe("email invite flow", () => { + test("user accepting email invite should be able to signup and fill out form", async ({ + page, + }) => { + await test.step("user can go to invite page and see they are allowed to signup", async () => { + const invite = community.invites.happyPathEmailInvite; + const redirectTo = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; + const inviteUrl = createInviteUrl(invite.inviteToken, redirectTo); + + await page.goto(inviteUrl); + await expect(page).toHaveURL(inviteUrl); + + await page.getByText("Create account").waitFor({ + state: "visible", + timeout: 1_000, + }); + }); + + await test.step("User can click create account and be redirected to signup page", async () => { + await page.getByRole("button", { name: "Create account" }).click({ + timeout: 2_000, + }); + await page.waitForURL(`**/public/signup**`); + }); + + await test.step("user will be shown error if they try to signup with a different email", async () => { + await page.getByLabel("Email").fill(email1); + await page.getByLabel("First name").fill(firstName1); + await page.getByLabel("Last name").fill(lastName1); + await page.getByLabel("Password").fill("password"); + await page.getByTestId("signup-submit-button").click({ + timeout: 2000, + }); + + await page.getByText("Email does not match invite").first().waitFor({ + state: "visible", + timeout: 1000, + }); + + // dismiss notification + await page + .getByRole("region", { name: "Notifications (F8)" }) + .getByRole("button") + .click(); + }); + + await test.step("user can signup with the correct email", async () => { + await page.getByLabel("Email").fill(community.invites.happyPathEmailInvite.user.email); + await page + .getByLabel("First name") + .fill(community.invites.happyPathEmailInvite.user.firstName); + await page + .getByLabel("Last name") + .fill(community.invites.happyPathEmailInvite.user.lastName!); + await page.getByLabel("Password").fill("password"); + await page.getByTestId("signup-submit-button").click({ + timeout: 2000, + }); + + await page.waitForURL(`**/public/forms/${community.forms.Evaluation.slug}/fill**`, { + timeout: 10_000, + }); + }); + + await test.step("user can fill out form", async () => { + await page.getByLabel("Title").fill("Test title"); + await page.getByLabel("Content").fill("Test content"); + await page.getByLabel("Email").fill(email2); + await page.getByRole("button", { name: "Submit" }).click({ + timeout: 2000, + }); + }); + await test.step.skip("user has correct permissions afterwards", async () => {}); + }); + + test("user who did not complete signup can return to invite and complete it", async ({ + page, + browser, + }) => { + const invite = community.invites.acceptedYetNotCompletedUserInvite; + const redirectTo = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; + const inviteUrl = createInviteUrl(invite.inviteToken, redirectTo); + + await page.goto(inviteUrl); + + await test.step("user can click create account and be redirected to signup page", async () => { + await page.getByRole("link", { name: "Complete signup" }).click({ + timeout: 2_000, + }); + await page.waitForURL(`**/public/signup**`, { timeout: 10_000 }); + }); + + await test.step("user can signup with the correct email", async () => { + await page + .getByLabel("Email") + .fill(community.invites.acceptedYetNotCompletedUserInvite.user.email); + await page + .getByLabel("First name") + .fill(community.invites.acceptedYetNotCompletedUserInvite.user.firstName); + await page + .getByLabel("Last name") + .fill(community.invites.acceptedYetNotCompletedUserInvite.user.lastName!); + await page.getByLabel("Password").fill("password"); + await page.getByTestId("signup-submit-button").click({ + timeout: 2000, + }); + + await page.waitForURL(`**/public/forms/${community.forms.Evaluation.slug}/fill**`); + }); + + await test.step("user can fill out form", async () => { + await page.getByLabel("Title").fill("Test title"); + await page.getByLabel("Content").fill("Test content"); + await page.getByLabel("Email").fill(email2); + await page.getByRole("button", { name: "Submit" }).click({ + timeout: 2000, + }); + }); + }); + + test("user can return to completed invite and be redirected to form", async ({ page }) => { + const invite = community.invites.completedUserInvite; + const redirectTo = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; + const inviteUrl = createInviteUrl(invite.inviteToken, redirectTo); + + await page.goto(inviteUrl); + await expectInvalidInvite(invite.inviteToken, page).toShow( + "This invite has already been completed." + ); + + await test.step("user can click login link and be redirected to form", async () => { + await page.getByRole("link", { name: "Login to continue to destination" }).click({ + timeout: 2_000, + }); + await page.waitForURL(`**/login**`); + await page.getByLabel("Email").fill(community.invites.completedUserInvite.user.email); + await page.getByLabel("Password").fill("password"); + await page.getByText("Sign In").click({ timeout: 2_000 }); + + await page.waitForURL(`**/public/forms/${community.forms.Evaluation.slug}/fill**`, { + timeout: 10_000, + }); + }); + + await test.step("user is automatically redirected when returning to invite", async () => { + await page.goto(inviteUrl); + await page.waitForURL(`**/public/forms/${community.forms.Evaluation.slug}/fill**`, { + timeout: 10_000, + }); + }); + }); +}); + +test.describe("user invite flow", () => { + test("wrong user is logged in, they log out, then accept as usual", async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.loginAndWaitForNavigation(community.users.admin.email, "password"); + + await test.step("visit invite and see wrong account message", async () => { + await expectInvalidInvite( + community.invites.happyPathUserInvite.inviteToken, + page + ).toShow("Wrong account"); + }); + + await test.step("logout and login as invited user", async () => { + await page.getByRole("button", { name: "Logout" }).click(); + }); + + await test.step("revisit invite see correct message", async () => { + await page.getByText("You've Been Invited", { exact: true }).waitFor({ + state: "visible", + timeout: 5_000, + }); + }); + + await test.step("login as invited user", async () => { + await page.getByRole("button", { name: "Log In" }).click({ + timeout: 2_000, + }); + await page.waitForURL("**/login?**", { timeout: 5_000 }); + await page.getByLabel("Email").fill(community2.users.invitee1.email); + await page.getByLabel("Password").fill("password"); + await page.getByText("Sign In").click({ + timeout: 2_000, + }); + await page.waitForTimeout(1_000); + }); + + await test.step("get redirected back to invite, accept, then see correct form", async () => { + await page.waitForURL(`**/public/invite?**`, { timeout: 5_000 }); + await page.getByRole("button", { name: "Accept" }).click({ + timeout: 2_000, + }); + + await page.waitForURL(`**/public/forms/${community.forms.Evaluation.slug}/fill**`); + + await page.getByLabel("Title").fill("Test title"); + await page.getByLabel("Content").fill("Test content"); + await page.getByLabel("Email").fill(community.users.user2.email); + await page.getByRole("button", { name: "Submit" }).click({ + timeout: 2_000, + }); + }); + }); +}); + +test.describe("invite reject flow", () => { + test("user can reject a pending invite", async ({ page }) => { + const invite = community.invites.rejectEmailInvite; + await test.step("user can access invite page and see reject option", async () => { + const redirectTo = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; + const inviteUrl = createInviteUrl(invite.inviteToken, redirectTo); + + await page.goto(inviteUrl); + await expect(page).toHaveURL(inviteUrl); + + await expect(page.getByRole("button", { name: "Reject" })).toBeVisible({ + timeout: 1000, + }); + }); + + await test.step("user can click reject and confirm rejection", async () => { + await page.getByRole("button", { name: "Reject" }).click({ + timeout: 2000, + }); + + await page.getByRole("button", { name: "Reject" }).click({ + timeout: 2000, + }); + + await expect(page.getByText("You have rejected the invite")).toBeVisible({ + timeout: 1000, + }); + }); + + await test.step("Going back to the invite URL shows the rejected state", async () => { + // Reload the page to verify the rejected state persists + await expectInvalidInvite(invite.inviteToken, page).toShow( + "You have already rejected this invite." + ); + }); + }); +}); + +test.describe("different form/invite types", () => { + test("user can accept invite with pub level form", async () => { + await test.step("user can access invite with pub level form", async () => { + const invite = community.invites.pubLevelFormInvite; + const redirectTo = `/c/${community.community.slug}/public/forms/${community.forms.CommunityForm.slug}/fill?pubId=${pub1Id}`; + const inviteUrl = createInviteUrl(invite.inviteToken, redirectTo); + + await page.goto(inviteUrl); + await expect(page).toHaveURL(inviteUrl); + + await page.getByText("Create account").waitFor({ + state: "visible", + timeout: 1000, + }); + }); + + await test.step("User can sign up", async () => { + await page.getByRole("button", { name: "Create account" }).click({ + timeout: 2000, + }); + await page.waitForURL(`**/public/signup**`); + + await page.getByLabel("Email").fill(email4); + await page.getByLabel("First name").fill(firstName4); + await page.getByLabel("Last name").fill(lastName4); + await page.getByLabel("Password").fill("password"); + await page.getByTestId("signup-submit-button").click({ + timeout: 2000, + }); + + // Should be redirected to the community form + await page.waitForURL(`**/public/forms/${community.forms.CommunityForm.slug}/fill**`); + }); + + await test.step("User can fill out community form", async () => { + // Fill out the community form, which has different fields + await page.getByLabel("Form Title").fill("Community Test Title"); + await page.getByRole("button", { name: "Submit" }).click({ + timeout: 2000, + }); + }); + }); +}); + +test.describe("different roles in invites", () => { + test("user can accept invite with admin role", async () => { + await test.step("user can access invite with admin role", async () => { + const invite = community.invites.adminRoleInvite; + const redirectTo = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; + const inviteUrl = createInviteUrl(invite.inviteToken, redirectTo); + + await page.goto(inviteUrl); + await expect(page).toHaveURL(inviteUrl); + + await page.getByText("Create account").waitFor({ + state: "visible", + timeout: 1000, + }); + }); + + await test.step("user can sign up with admin invite", async () => { + await page.getByRole("button", { name: "Create account" }).click({ + timeout: 2000, + }); + await page.waitForURL(`**/public/signup**`); + + await page.getByLabel("Email").fill(email5); + await page.getByLabel("First name").fill(firstName5); + await page.getByLabel("Last name").fill(lastName5); + await page.getByLabel("Password").fill("password"); + await page.getByTestId("signup-submit-button").click({ + timeout: 2000, + }); + + // Should be redirected to the form + await page.waitForURL(`**/public/forms/${community.forms.Evaluation.slug}/fill**`); + }); + + await test.step("admin can fill out form", async () => { + await page.getByLabel("Title").fill("Admin Test Title"); + await page.getByLabel("Content").fill("Content from admin"); + await page.getByLabel("Email").fill(email5); + await page.getByRole("button", { name: "Submit" }).click({ + timeout: 2000, + }); + }); + + await test.step("Admin should have admin permissions in the community", async () => { + const fieldsPage = new FieldsPage(page, community.community.slug); + await fieldsPage.goto(); + await fieldsPage.addField( + "Only an admin could have created this", + CoreSchemaType.String + ); + }); + }); +}); + +test.describe.skip("multiple invites for same user", () => { + test.skip("user with multiple pending invites can accept them in sequence", async () => { + // This would test a user accepting multiple different invites one after another + // Each giving different permissions or access to different forms + // 1. Access first invite + // 2. Sign up + // 3. Verify access + // 4. Access second invite + // 5. Verify additional access gained + }); + + test.skip("user gets correct merged permissions when accepting multiple invites", async () => { + // This would test that permissions are properly combined when a user + // accepts multiple invites with different permission levels + }); +}); + +test.describe.skip("invite expiration handling", () => { + test.skip("nearly expired invite allows user to request a new invite", async () => { + // 1. User accesses an invite that's about to expire + // 2. UI shows a warning about expiration + // 3. User is given option to request a new invite + // 4. System generates new invite with extended expiration + }); + + test.skip("user gets notification about upcoming invite expiration", async () => { + // For invites attached to a user account, they should get notified + // before the invite expires + }); + + test.skip("admin can see and extend expiring invites", async () => { + // Test admin interface for managing and extending invite expirations + }); +}); diff --git a/core/playwright/member.spec.ts b/core/playwright/member.spec.ts index 2acb97879d..bcdad63794 100644 --- a/core/playwright/member.spec.ts +++ b/core/playwright/member.spec.ts @@ -32,25 +32,6 @@ test.afterAll(async () => { }); test.describe("Community members", () => { - let newUserEmail: string; - - test("Can add a new member", async () => { - const membersPage = new MembersPage(page, COMMUNITY_SLUG); - await membersPage.goto(); - - const { email, firstName, lastName, isSuperAdmin, role } = await membersPage.addNewUser( - faker.internet.email() - ); - - expect(email).toBeTruthy(); - expect(firstName).toBeTruthy(); - expect(lastName).toBeTruthy(); - expect(isSuperAdmin).toBe(false); - expect(role).toEqual("editor"); - - newUserEmail = email; - }); - test("Can add an existing user", async () => { const membersPage = new MembersPage(page, COMMUNITY_SLUG); await membersPage.goto(); @@ -73,44 +54,67 @@ test.describe("Community members", () => { expect(page.getByText("No results.")).toBeVisible(); }); - test("New user signup", async ({ page }) => { + test("new user signup flow", async ({ browser }) => { + let newUserEmail: string; + + const membersPage = new MembersPage(page, COMMUNITY_SLUG); + await membersPage.goto(); + + const { email, firstName, lastName, isSuperAdmin, role } = await membersPage.addNewUser( + faker.internet.email() + ); + + expect(email).toBeTruthy(); + expect(firstName).toBeTruthy(); + expect(lastName).toBeTruthy(); + expect(isSuperAdmin).toBe(false); + expect(role).toEqual("editor"); + + newUserEmail = email; + expect(newUserEmail).toBeTruthy(); - const firstPartOfEmail = newUserEmail.split("@")[0]; - const inviteEmail = await ( - await inbucketClient.getMailbox(firstPartOfEmail) - ).getLatestMessage(10); - const joinLink = inviteEmail.message.body.text?.match(/(https?:\/\/.*?)\s/)?.[1]!; + await test.step("user can signup", async () => { + const page = await browser.newPage(); - expect(joinLink).toBeTruthy(); + const firstPartOfEmail = newUserEmail.split("@")[0]; + const inviteEmail = await ( + await inbucketClient.getMailbox(firstPartOfEmail) + ).getLatestMessage(10); - await page.goto(joinLink); - await page.waitForURL(/\/signup.*/); + const joinLink = inviteEmail.message.body.text?.match(/(https?:\/\/.*?)\s/)?.[1]!; - await page.locator("input[name='password']").fill("password"); + expect(joinLink).toBeTruthy(); - await page.click("button[type='submit']"); + await page.goto(joinLink); + await page.waitForURL(/\/signup.*/); - await page.waitForURL(/\/c\/.*?\/stages/); + await page.locator("input[name='password']").fill("password"); - await page.close(); - }); + await page.click("button[type='submit']"); + + await page.waitForURL(/\/c\/.*?\/stages/); + + await page.close(); + }); - //TODO: not sure why the expect fails here - test.fixme("User is not able to sign up twice", async ({ page }) => { - const inviteEmail = await ( - await inbucketClient.getMailbox(newUserEmail.split("@")[0]) - ).getLatestMessage(20); + //TODO: not sure why the expect fails here + await test.step.skip("User is not able to sign up twice", async () => { + const page = await browser.newPage(); + const inviteEmail = await ( + await inbucketClient.getMailbox(newUserEmail.split("@")[0]) + ).getLatestMessage(20); - const joinLink = inviteEmail.message.body.text?.match(/(https?:\/\/.*?)\s/)?.[1]!; + const joinLink = inviteEmail.message.body.text?.match(/(https?:\/\/.*?)\s/)?.[1]!; - expect(joinLink).toBeTruthy(); + expect(joinLink).toBeTruthy(); - await page.goto(joinLink); - await page.waitForURL(/\/signup/); + await page.goto(joinLink); + await page.waitForURL(/\/signup/); - expect(page.getByText("You are not allowed to signup for an account")).toBeAttached(); - await page.close(); + expect(page.getByText("You are not allowed to signup for an account")).toBeAttached(); + await page.close(); + }); }); test.fixme("User is able to change their first and last name on signup", async () => {}); diff --git a/core/prisma/migrations/20250429103330_make_invite_table_more_clear/migration.sql b/core/prisma/migrations/20250429103330_make_invite_table_more_clear/migration.sql new file mode 100644 index 0000000000..2bdfbfd29d --- /dev/null +++ b/core/prisma/migrations/20250429103330_make_invite_table_more_clear/migration.sql @@ -0,0 +1,115 @@ +-- CreateEnum +CREATE TYPE "InviteStatus" AS ENUM ('created', 'pending', 'accepted', 'completed', 'rejected', 'revoked'); + +-- CreateTable +CREATE TABLE "invites" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid(), + "email" TEXT, + "userId" TEXT, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "communityId" TEXT NOT NULL, + "communityRole" "MemberRole" NOT NULL DEFAULT 'contributor', + "pubId" TEXT, + "pubRole" "MemberRole", + "stageId" TEXT, + "stageRole" "MemberRole", + "message" TEXT, + "lastSentAt" TIMESTAMP(3), + "status" "InviteStatus" NOT NULL DEFAULT 'created', + "invitedByUserId" TEXT, + "invitedByActionRunId" TEXT, + + CONSTRAINT "invites_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "invites_token_key" ON "invites"("token"); + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "communities"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "pubs"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "stages"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_invitedByActionRunId_fkey" FOREIGN KEY ("invitedByActionRunId") REFERENCES "action_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Add check constraints to enforce the type of the invite + +ALTER TABLE "invites" ADD CONSTRAINT "invites_email_user_id_check" +CHECK ( + ("email" IS NOT NULL AND "userId" IS NULL) OR + ("email" IS NULL AND "userId" IS NOT NULL) +); + + +-- enforce invited by constraints +ALTER TABLE "invites" ADD CONSTRAINT "invites_invited_by_check" +CHECK ( + ("invitedByUserId" IS NOT NULL AND "invitedByActionRunId" IS NULL) OR + ("invitedByUserId" IS NULL AND "invitedByActionRunId" IS NOT NULL) +); + +-- enforce last sent at and status constraints +ALTER TABLE "invites" ADD CONSTRAINT "invites_last_sent_status_check" +CHECK ( + ("lastSentAt" IS NOT NULL AND "status" IN ('accepted', 'pending', 'rejected', 'revoked', 'completed')) OR + ("lastSentAt" IS NULL AND "status" = 'created') +); + +-- CreateTable +CREATE TABLE "invite_forms" ( + "inviteId" TEXT NOT NULL, + "formId" TEXT NOT NULL, + "type" "MembershipType" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invite_forms_pkey" PRIMARY KEY ("inviteId","formId","type") +); + +-- CreateIndex +CREATE UNIQUE INDEX "invite_forms_inviteId_formId_type_key" ON "invite_forms"("inviteId", "formId", "type"); + +-- AddForeignKey +ALTER TABLE "invite_forms" ADD CONSTRAINT "invite_forms_inviteId_fkey" FOREIGN KEY ("inviteId") REFERENCES "invites"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invite_forms" ADD CONSTRAINT "invite_forms_formId_fkey" FOREIGN KEY ("formId") REFERENCES "forms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Add check constraint +-- function because you cannot use subqueries in check constraints +CREATE OR REPLACE FUNCTION check_invite_has_pub_or_stage(type "MembershipType", invite_id TEXT) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN CASE + WHEN "type" = 'pub'::"MembershipType" THEN EXISTS ( + SELECT 1 FROM "invites" + WHERE "invites"."id" = invite_id + AND "invites"."pubId" IS NOT NULL + ) + WHEN "type" = 'stage'::"MembershipType" THEN EXISTS ( + SELECT 1 FROM "invites" + WHERE "invites"."id" = invite_id + AND "invites"."stageId" IS NOT NULL + ) + ELSE TRUE + END; +END; +$$ LANGUAGE plpgsql; + +-- Add check constraint using the function +ALTER TABLE "invite_forms" ADD CONSTRAINT "invite_forms_check_pub_stage_form_exists" +CHECK (check_invite_has_pub_or_stage("type", "inviteId")); \ No newline at end of file diff --git a/core/prisma/migrations/20250429103331_add_invites_history_history_table/migration.sql b/core/prisma/migrations/20250429103331_add_invites_history_history_table/migration.sql new file mode 100644 index 0000000000..9811065c9f --- /dev/null +++ b/core/prisma/migrations/20250429103331_add_invites_history_history_table/migration.sql @@ -0,0 +1,67 @@ +/* + Warnings: + + - Added the required column `lastModifiedBy` to the `invites` table without a default value. This is not possible if the table is not empty. + */ +-- AlterTable +-- first we add the column with a default value +ALTER TABLE "invites" + ADD COLUMN "lastModifiedBy" modified_by_type NOT NULL DEFAULT CONCAT('unknown', '|', FLOOR(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000)); + +-- then we remove the default value +ALTER TABLE "invites" + ALTER COLUMN "lastModifiedBy" DROP DEFAULT; + +-- CreateTable +CREATE TABLE "invites_history"( + "id" text NOT NULL DEFAULT gen_random_uuid(), + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "operationType" "OperationType" NOT NULL, + "oldRowData" jsonb, + "newRowData" jsonb, + "inviteId" text, + "userId" text, + "apiAccessTokenId" text, + "actionRunId" text, + "other" text, + CONSTRAINT "invites_history_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "invites_history" + ADD CONSTRAINT "invites_history_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites_history" + ADD CONSTRAINT "invites_history_apiAccessTokenId_fkey" FOREIGN KEY ("apiAccessTokenId") REFERENCES "api_access_tokens"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites_history" + ADD CONSTRAINT "invites_history_actionRunId_fkey" FOREIGN KEY ("actionRunId") REFERENCES "action_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE invites_history + ADD CONSTRAINT chk_invites_history_crudtype_rowdata CHECK (("operationType" = 'insert' AND "oldRowData" IS NULL AND "newRowData" IS NOT NULL) OR ("operationType" = 'update' AND "oldRowData" IS NOT NULL AND "newRowData" IS NOT NULL) OR ("operationType" = 'delete' AND "oldRowData" IS NOT NULL AND "newRowData" IS NULL)); + +-- backfill invites_history with existing data +-- we just set it to insert the current row data, as we do not know who created it +-- we do not set a perpetrator for the existing data, as it is not possible to know who created it +-- setting a createAt manually is risky, as the base table might not have a createdAt/updateAt column. therefore we set the base case to the current timestamp +INSERT INTO "invites_history" ( + "operationType", + "oldRowData", + "newRowData", + "inviteId" +) +SELECT + 'insert'::"OperationType", + NULL, + row_to_json(t), + t.id +FROM + "invites" t; + +CREATE TRIGGER trigger_invites_history + AFTER INSERT OR UPDATE ON invites + FOR EACH ROW + EXECUTE FUNCTION f_generic_history('inviteId'); + diff --git a/core/prisma/migrations/20250429103332_update_comments/migration.sql b/core/prisma/migrations/20250429103332_update_comments/migration.sql new file mode 100644 index 0000000000..b02065ebce --- /dev/null +++ b/core/prisma/migrations/20250429103332_update_comments/migration.sql @@ -0,0 +1,214 @@ +-- generator-version: 1.0.0 + +-- Model invites_history comments + + + +-- Model pub_values_history comments + + + +-- Model users comments + + + +-- Model sessions comments + +COMMENT ON COLUMN "sessions"."type" IS 'With what type of token is this session created? Used for determining on a page-by-page basis whether to allow a certain session to access it. For instance, a verify email token/session should not allow you to access the password reset page.'; + + +-- Model auth_tokens comments + + + +-- Model communities comments + + + +-- Model pubs comments + + + +-- Model pub_fields comments + + + +-- Model PubFieldSchema comments + +COMMENT ON COLUMN "PubFieldSchema"."schema" IS '@type(JSONSchemaType, ''ajv'', true, false, true)'; + + +-- Model pub_values comments + +COMMENT ON COLUMN "pub_values"."lastModifiedBy" IS '@type(LastModifiedBy, ''../types'', true, false, true)'; + + +-- Model pub_types comments + + + +-- Model _PubFieldToPubType comments + + + +-- Model stages comments + + + +-- Model PubsInStages comments + + + +-- Model move_constraint comments + + + +-- Model member_groups comments + + + +-- Model community_memberships comments + + + +-- Model pub_memberships comments + + + +-- Model stage_memberships comments + + + +-- Model action_instances comments + + + +-- Model action_runs comments + + + +-- Model rules comments + + + +-- Model forms comments + + + +-- Model form_elements comments + + + +-- Model api_access_tokens comments + + + +-- Model api_access_logs comments + + + +-- Model api_access_permissions comments + +COMMENT ON COLUMN "api_access_permissions"."constraints" IS '@type(ApiAccessPermissionConstraints, ''../types'', true, false, true)'; + + +-- Model membership_capabilities comments + + + +-- Model invites comments + +COMMENT ON COLUMN "invites"."lastModifiedBy" IS '@type(LastModifiedBy, ''../types'', true, false, true)'; + + +-- Model invite_forms comments + + + +-- Enum AuthTokenType comments + +COMMENT ON TYPE "AuthTokenType" IS '@property generic - For most use-cases. This will just authenticate you with a regular session. +@property passwordReset - For resetting your password only +@property signup - For signing up, but also when you''re invited to a community +@property verifyEmail - For verifying your email address'; + + +-- Enum CoreSchemaType comments + + + + +-- Enum OperationType comments + + + + +-- Enum MemberRole comments + + + + +-- Enum Action comments + + + + +-- Enum ActionRunStatus comments + + + + +-- Enum Event comments + + + + +-- Enum FormAccessType comments + + + + +-- Enum StructuralFormElement comments + + + + +-- Enum ElementType comments + + + + +-- Enum InputComponent comments + + + + +-- Enum ApiAccessType comments + + + + +-- Enum ApiAccessScope comments + + + + +-- Enum Capabilities comments + + + + +-- Enum MembershipType comments + + + + +-- Enum InviteStatus comments + +COMMENT ON TYPE "InviteStatus" IS 'Status of an invite +@property created - The invite has been created, but not yet sent +@property pending - The invite has been sent, but not yet accepted +@property accepted - The invite has been accepted, but the relevant signup step has not been completed +@property completed - The invite has been accepted, and the relevant signup step has been completed +@property rejected - The invite has been rejected +@property revoked - The invite has been revoked by the user who created it, or by a sufficient authority'; diff --git a/core/prisma/migrations/20250429153030_remove_email_column_from_invites/migration.sql b/core/prisma/migrations/20250429153030_remove_email_column_from_invites/migration.sql new file mode 100644 index 0000000000..d55029b6f5 --- /dev/null +++ b/core/prisma/migrations/20250429153030_remove_email_column_from_invites/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - You are about to drop the column `email` on the `invites` table. All the data in the column will be lost. + - Made the column `userId` on table `invites` required. This step will fail if there are existing NULL values in that column. + +*/ + +-- remove all invites that have a user +DELETE FROM "invites" WHERE "userId" IS NOT NULL; + +-- DropForeignKey +ALTER TABLE "invites" DROP CONSTRAINT "invites_userId_fkey"; + +-- AlterTable +ALTER TABLE "invites" DROP COLUMN "email", +ALTER COLUMN "userId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "isProvisional" BOOLEAN NOT NULL DEFAULT false; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/core/prisma/schema/comments/.comments-lock b/core/prisma/schema/comments/.comments-lock index 0a67435d67..1a704607f6 100644 --- a/core/prisma/schema/comments/.comments-lock +++ b/core/prisma/schema/comments/.comments-lock @@ -1,11 +1,16 @@ -- generator-version: 1.0.0 +-- Model invites_history comments + + + -- Model pub_values_history comments -- Model users comments +COMMENT ON COLUMN "users"."isProvisional" IS 'Indicates whether a user is provisional, meaning they were added through an invite and need to accept it to become a full user'; -- Model sessions comments @@ -112,6 +117,15 @@ COMMENT ON COLUMN "api_access_permissions"."constraints" IS '@type(ApiAccessPerm +-- Model invites comments + +COMMENT ON COLUMN "invites"."lastModifiedBy" IS '@type(LastModifiedBy, ''../types'', true, false, true)'; + + +-- Model invite_forms comments + + + -- Enum AuthTokenType comments COMMENT ON TYPE "AuthTokenType" IS '@property generic - For most use-cases. This will just authenticate you with a regular session. @@ -188,3 +202,14 @@ COMMENT ON TYPE "AuthTokenType" IS '@property generic - For most use-cases. This -- Enum MembershipType comments + + +-- Enum InviteStatus comments + +COMMENT ON TYPE "InviteStatus" IS 'Status of an invite +@property created - The invite has been created, but not yet sent +@property pending - The invite has been sent, but not yet accepted +@property accepted - The invite has been accepted, but the relevant signup step has not been completed +@property completed - The invite has been accepted, and the relevant signup step has been completed +@property rejected - The invite has been rejected +@property revoked - The invite has been revoked by the user who created it, or by a sufficient authority'; diff --git a/core/prisma/schema/history-tables/InviteHistory.prisma b/core/prisma/schema/history-tables/InviteHistory.prisma new file mode 100644 index 0000000000..d9a316e214 --- /dev/null +++ b/core/prisma/schema/history-tables/InviteHistory.prisma @@ -0,0 +1,27 @@ +model InviteHistory { + id String @id @default(dbgenerated("gen_random_uuid()")) + createdAt DateTime @default(now()) + operationType OperationType + + // has check constraint to ensure that oldRowData and newRowData are not both null + // see ./migrations/20241203164958_add_history_table/migration.sql + // type is the type of the Table that is being changed, e.g `PubValues` for PubValuesHistory + // using a kysely pre-render hook + oldRowData Json? + newRowData Json? + + // primary key of the row that was changed + inviteId String? + + // identifying information + user User? @relation(fields: [userId], references: [id]) + userId String? + apiAccessToken ApiAccessToken? @relation(fields: [apiAccessTokenId], references: [id]) + apiAccessTokenId String? + actionRun ActionRun? @relation(fields: [actionRunId], references: [id]) + actionRunId String? + // set to `system` if the change was made by the system, eg during seeds + other String? + + @@map(name: "invites_history") +} diff --git a/core/prisma/schema/schema.dbml b/core/prisma/schema/schema.dbml index 97dc4ba345..832e164746 100644 --- a/core/prisma/schema/schema.dbml +++ b/core/prisma/schema/schema.dbml @@ -2,6 +2,22 @@ //// THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) //// ------------------------------------------------------ +Table invites_history { + id String [pk] + createdAt DateTime [default: `now()`, not null] + operationType OperationType [not null] + oldRowData Json + newRowData Json + inviteId String + user users + userId String + apiAccessToken api_access_tokens + apiAccessTokenId String + actionRun action_runs + actionRunId String + other String +} + Table pub_values_history { id String [pk] createdAt DateTime [default: `now()`, not null] @@ -31,6 +47,7 @@ Table users { isSuperAdmin Boolean [not null, default: false] passwordHash String isVerified Boolean [not null, default: false] + isProvisional Boolean [not null, default: false, note: 'Indicates whether a user is provisional, meaning they were added through an invite and need to accept it to become a full user'] memberGroups member_groups [not null] AuthToken auth_tokens [not null] actionRuns action_runs [not null] @@ -40,6 +57,9 @@ Table users { pubMemberships pub_memberships [not null] stageMemberships stage_memberships [not null] PubValueHistory pub_values_history [not null] + InvitedUser invites [not null] + InvitedByUser invites [not null] + InviteHistory invites_history [not null] } Table sessions { @@ -79,6 +99,7 @@ Table communities { Form forms [not null] pubFields pub_fields [not null] members community_memberships [not null] + Invite invites [not null] } Table pubs { @@ -96,6 +117,7 @@ Table pubs { actionRuns action_runs [not null] relatedValues pub_values [not null] members pub_memberships [not null] + Invite invites [not null] } Table pub_fields { @@ -191,6 +213,7 @@ Table stages { actionInstances action_instances [not null] formElements form_elements [not null] members stage_memberships [not null] + Invite invites [not null] } Table PubsInStages { @@ -309,6 +332,8 @@ Table action_runs { sourceActionRunId String sourceActionRun action_runs sequentialActionRuns action_runs [not null] + InvitedByActionRun invites [not null] + InviteHistory invites_history [not null] } Table rules { @@ -337,6 +362,7 @@ Table forms { isDefault Boolean [not null, default: false] createdAt DateTime [default: `now()`, not null] updatedAt DateTime [default: `now()`, not null] + inviteForms invite_forms [not null] StageMembership stage_memberships [not null] CommunityMembership community_memberships [not null] PubMembership pub_memberships [not null] @@ -388,6 +414,7 @@ Table api_access_tokens { accessRules api_access_permissions [not null] logs api_access_logs [not null] PubValueHistory pub_values_history [not null] + InviteHistory invites_history [not null] } Table api_access_logs { @@ -419,6 +446,49 @@ Table membership_capabilities { } } +Table invites { + id String [pk] + lastModifiedBy String [not null, note: '@type(LastModifiedBy, \'../types\', true, false, true)'] + InvitedUser users [not null] + userId String [not null] + token String [unique, not null] + expiresAt DateTime [not null] + createdAt DateTime [default: `now()`, not null] + updatedAt DateTime [default: `now()`, not null] + community communities [not null] + communityId String [not null] + communityRole MemberRole [not null, default: 'contributor'] + Pub pubs + pubId String + pubRole MemberRole + Stage stages + stageId String + stageRole MemberRole + message String + lastSentAt DateTime + status InviteStatus [not null, default: 'created'] + InvitedByUser users + invitedByUserId String + InvitedByActionRun action_runs + invitedByActionRunId String + forms invite_forms [not null] +} + +Table invite_forms { + invite invites [not null] + inviteId String [not null] + form forms [not null] + formId String [not null] + type MembershipType [not null] + createdAt DateTime [default: `now()`, not null] + updatedAt DateTime [default: `now()`, not null] + + indexes { + (inviteId, formId, type) [pk] + (inviteId, formId, type) [unique] + } +} + Table MemberGroupToUser { membergroupsId String [ref: > member_groups.id] usersId String [ref: > users.id] @@ -582,6 +652,21 @@ Enum MembershipType { pub } +Enum InviteStatus { + created + pending + accepted + completed + rejected + revoked +} + +Ref: invites_history.userId > users.id + +Ref: invites_history.apiAccessTokenId > api_access_tokens.id + +Ref: invites_history.actionRunId > action_runs.id + Ref: pub_values_history.userId > users.id Ref: pub_values_history.apiAccessTokenId > api_access_tokens.id @@ -678,4 +763,20 @@ Ref: api_access_tokens.issuedById > users.id [delete: Set Null] Ref: api_access_logs.accessTokenId > api_access_tokens.id [delete: Set Null] -Ref: api_access_permissions.apiAccessTokenId > api_access_tokens.id [delete: Cascade] \ No newline at end of file +Ref: api_access_permissions.apiAccessTokenId > api_access_tokens.id [delete: Cascade] + +Ref: invites.userId > users.id [delete: Cascade] + +Ref: invites.communityId > communities.id [delete: Cascade] + +Ref: invites.pubId > pubs.id [delete: Set Null] + +Ref: invites.stageId > stages.id [delete: Set Null] + +Ref: invites.invitedByUserId > users.id [delete: Cascade] + +Ref: invites.invitedByActionRunId > action_runs.id [delete: Set Null] + +Ref: invite_forms.inviteId > invites.id [delete: Cascade] + +Ref: invite_forms.formId > forms.id [delete: Cascade] \ No newline at end of file diff --git a/core/prisma/schema/schema.prisma b/core/prisma/schema/schema.prisma index ee37248881..a35507081f 100644 --- a/core/prisma/schema/schema.prisma +++ b/core/prisma/schema/schema.prisma @@ -17,18 +17,20 @@ generator comments { } model User { - id String @id @default(dbgenerated("gen_random_uuid()")) - slug String @unique - email String @unique - firstName String - lastName String? - orcid String? - avatar String? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - isSuperAdmin Boolean @default(false) - passwordHash String? - isVerified Boolean @default(false) + id String @id @default(dbgenerated("gen_random_uuid()")) + slug String @unique + email String @unique + firstName String + lastName String? + orcid String? + avatar String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + isSuperAdmin Boolean @default(false) + passwordHash String? + isVerified Boolean @default(false) + /// Indicates whether a user is provisional, meaning they were added through an invite and need to accept it to become a full user + isProvisional Boolean @default(false) memberGroups MemberGroup[] AuthToken AuthToken[] @@ -39,6 +41,9 @@ model User { pubMemberships PubMembership[] stageMemberships StageMembership[] PubValueHistory PubValueHistory[] + InvitedUser Invite[] @relation("invited_user") + InvitedByUser Invite[] @relation("invited_by") + InviteHistory InviteHistory[] @@map(name: "users") } @@ -99,6 +104,7 @@ model Community { Form Form[] pubFields PubField[] members CommunityMembership[] + Invite Invite[] @@map(name: "communities") } @@ -125,6 +131,7 @@ model Pub { members PubMembership[] searchVector Unsupported("tsvector")? + Invite Invite[] @@index([searchVector], type: Gin) @@map(name: "pubs") @@ -217,9 +224,9 @@ model PubType { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - fields PubFieldToPubType[] - pubs Pub[] - Form Form[] + fields PubFieldToPubType[] + pubs Pub[] + Form Form[] formElements FormElement[] @@unique([name, communityId]) @@ -258,6 +265,7 @@ model Stage { actionInstances ActionInstance[] formElements FormElement[] members StageMembership[] + Invite Invite[] @@map(name: "stages") } @@ -427,6 +435,8 @@ model ActionRun { // action runs that were triggered by this action run sequentialActionRuns ActionRun[] @relation("source_action_run") + InvitedByActionRun Invite[] @relation("invited_by_action_run") + InviteHistory InviteHistory[] @@map(name: "action_runs") } @@ -480,6 +490,7 @@ model Form { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + inviteForms InviteForm[] StageMembership StageMembership[] CommunityMembership CommunityMembership[] PubMembership PubMembership[] @@ -524,24 +535,24 @@ enum InputComponent { // Either a structural element like a header, hr etc. or a pubfield input model FormElement { - id String @id @default(dbgenerated("gen_random_uuid()")) - type ElementType - fieldId String? - field PubField? @relation(fields: [fieldId], references: [id], onDelete: Cascade) - formId String - rank String // Uses "C" collation + id String @id @default(dbgenerated("gen_random_uuid()")) + type ElementType + fieldId String? + field PubField? @relation(fields: [fieldId], references: [id], onDelete: Cascade) + formId String + rank String // Uses "C" collation // label is only used by elements with type: ElementType.button. Pubfield inputs put everything in config - label String? - element StructuralFormElement? - component InputComponent? - config Json? - content String? - required Boolean? - form Form @relation(fields: [formId], references: [id], onDelete: Cascade) - stage Stage? @relation(fields: [stageId], references: [id], onDelete: SetNull) - stageId String? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + label String? + element StructuralFormElement? + component InputComponent? + config Json? + content String? + required Boolean? + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + stage Stage? @relation(fields: [stageId], references: [id], onDelete: SetNull) + stageId String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt relatedPubTypes PubType[] @@unique([type, label, formId]) @@ -564,6 +575,7 @@ model ApiAccessToken { accessRules ApiAccessPermission[] logs ApiAccessLog[] PubValueHistory PubValueHistory[] + InviteHistory InviteHistory[] @@index([token], name: "token_idx") @@map(name: "api_access_tokens") @@ -663,3 +675,93 @@ model MembershipCapabilities { @@id([role, type, capability]) @@map(name: "membership_capabilities") } + +/// Status of an invite +/// @property created - The invite has been created, but not yet sent +/// @property pending - The invite has been sent, but not yet accepted +/// @property accepted - The invite has been accepted, but the relevant signup step has not been completed +/// @property completed - The invite has been accepted, and the relevant signup step has been completed +/// @property rejected - The invite has been rejected +/// @property revoked - The invite has been revoked by the user who created it, or by a sufficient authority +enum InviteStatus { + created + pending + accepted + completed + rejected + revoked +} + +// invites are invitations to join a community +// invites can be for existing users, or for new users, but not both +// invites can bestow certain memberships on the user after the user joins the community +// NOTE: this table has a history table. +model Invite { + id String @id @default(dbgenerated("gen_random_uuid()")) + /// @type(LastModifiedBy, '../types', true, false, true) + lastModifiedBy String + + InvitedUser User @relation("invited_user", fields: [userId], references: [id], onDelete: Cascade) + userId String + + token String @unique + expiresAt DateTime + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + // community level permissions + // an invite must be for a community, and must have a community membership level set + community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) + communityId String + + // which role should be granted to the user after they join the community + communityRole MemberRole @default(contributor) + + // you can also directly invite a user to a pub or stage, but not both + // you can then also immediately allow them to use a certain form at that level + Pub Pub? @relation(fields: [pubId], references: [id], onDelete: SetNull) + pubId String? + pubRole MemberRole? + + Stage Stage? @relation(fields: [stageId], references: [id], onDelete: SetNull) + stageId String? + stageRole MemberRole? + + // the message that is sent in the invite. useful for resending invites + message String? + // when is the message last sent. total send count is kept track of by the invite history table + lastSentAt DateTime? + + status InviteStatus @default(created) + + // useful to keep track of who invited the user. slightly different from `lastModifiedBy` + InvitedByUser User? @relation("invited_by", fields: [invitedByUserId], references: [id], onDelete: SetNull) + invitedByUserId String? + InvitedByActionRun ActionRun? @relation("invited_by_action_run", fields: [invitedByActionRunId], references: [id], onDelete: SetNull) + invitedByActionRunId String? + + forms InviteForm[] + + // this table has some constraints that make it conform to the type in `db/types/Invite.ts` + // see core/prisma/migrations/20250402130740_create_invite_table/migration.sql for details + @@map(name: "invites") +} + +model InviteForm { + invite Invite @relation(fields: [inviteId], references: [id], onDelete: Cascade) + inviteId String + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId String + type MembershipType + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + // ensure pub/stage forms only exist when pub or stage is set + + // has check constraint to ensure pub/stage forms only exist when pub or stage is set + // see core/prisma/migrations/20250407090041_reference_multiple_forms_in_invite/migration.sql for details + + @@id([inviteId, formId, type]) + @@unique([inviteId, formId, type]) + @@map(name: "invite_forms") +} diff --git a/core/prisma/seed.ts b/core/prisma/seed.ts index c13141283b..42180a9bd8 100644 --- a/core/prisma/seed.ts +++ b/core/prisma/seed.ts @@ -8,6 +8,7 @@ import { db } from "~/kysely/database"; import { isUniqueConstraintError } from "~/kysely/errors"; import { createPasswordHash } from "~/lib/authentication/password"; import { env } from "~/lib/env/env"; +import { setupInviteTestCommunity } from "./seeds/invite-test-community"; import { seedLegacy } from "./seeds/legacy"; import { seedStarter } from "./seeds/starter"; @@ -62,8 +63,8 @@ async function createUserMembers({ .executeTakeFirstOrThrow(); } -const legacyId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" as CommunitiesId; -const starterId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" as CommunitiesId; +const legacyId = "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa" as CommunitiesId; +const starterId = "bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb" as CommunitiesId; async function main() { // do not seed arcadia if the minimal seed flag is set @@ -78,14 +79,21 @@ async function main() { ) as CommunitiesId[]; logger.info("migrate graphile"); + const workerUtils = await makeWorkerUtils({ connectionString: env.DATABASE_URL, }); + + logger.info("drop existing jobs"); + await workerUtils.withPgClient(async (client) => { + await client.query(`DROP SCHEMA graphile_worker CASCADE`); + }); + await workerUtils.migrate(); const legacyPromise = shouldSeedLegacy ? seedLegacy(legacyId) : null; - await Promise.all([seedStarter(starterId), legacyPromise]); + await Promise.all([seedStarter(starterId), legacyPromise, setupInviteTestCommunity()]); await Promise.all([ createUserMembers({ diff --git a/core/prisma/seed/createSeed.ts b/core/prisma/seed/createSeed.ts index 8c93badecc..3b4402e808 100644 --- a/core/prisma/seed/createSeed.ts +++ b/core/prisma/seed/createSeed.ts @@ -3,6 +3,7 @@ import type { CommunitiesId } from "db/public"; import type { ApiTokenInitializer, FormInitializer, + InviteInitializer, PubFieldsInitializer, PubInitializer, PubTypeInitializer, @@ -24,6 +25,7 @@ export const createSeed = < const PI extends PubInitializer[], const F extends FormInitializer, const AI extends ApiTokenInitializer, + const II extends InviteInitializer, >(props: { community: { id?: CommunitiesId; @@ -39,6 +41,7 @@ export const createSeed = < pubs?: PI; forms?: F; apiTokens?: AI; + invites?: II; }) => props; export type Seed = Parameters[0]; @@ -53,7 +56,8 @@ export type CommunitySeedOutput> = Awaited< NonNullable, NonNullable, NonNullable, - NonNullable + NonNullable, + NonNullable > > >; diff --git a/core/prisma/seed/seedCommunity.ts b/core/prisma/seed/seedCommunity.ts index 03be78e46b..478f6aa9ac 100644 --- a/core/prisma/seed/seedCommunity.ts +++ b/core/prisma/seed/seedCommunity.ts @@ -23,6 +23,7 @@ import type { FormElements, Forms, FormsId, + InvitesId, NewCommunityMemberships, PubFields, PubsId, @@ -34,7 +35,12 @@ import type { Users, UsersId, } from "db/public"; -import type { ApiAccessPermissionConstraints, permissionsSchema } from "db/types"; +import type { + ApiAccessPermissionConstraints, + Invite, + NewInviteInput, + permissionsSchema, +} from "db/types"; import { Action as ActionName, CoreSchemaType, @@ -43,10 +49,12 @@ import { MemberRole, StructuralFormElement, } from "db/public"; +import { newInviteSchema } from "db/types"; import { logger } from "logger"; import { expect } from "utils"; import type { actions } from "~/actions/api"; +import type { MaybeHas } from "~/lib/types"; import { db } from "~/kysely/database"; import { createPasswordHash } from "~/lib/authentication/password"; import { createLastModifiedBy } from "~/lib/lastModifiedBy"; @@ -54,6 +62,8 @@ import { findRanksBetween } from "~/lib/rank"; import { createPubRecursiveNew } from "~/lib/server"; import { allPermissions, createApiAccessToken } from "~/lib/server/apiAccessTokens"; import { insertForm } from "~/lib/server/form"; +import { InviteService } from "~/lib/server/invites/InviteService"; +import { generateToken } from "~/lib/server/token"; import { slugifyString } from "~/lib/string"; export type PubFieldsInitializer = Record< @@ -328,6 +338,30 @@ export type ApiTokenInitializer = { }; }; +export type InviteInitializer< + PF extends PubFieldsInitializer, + PT extends PubTypeInitializer, + U extends UsersInitializer, + SI extends StagesInitializer, + FI extends FormInitializer, +> = { + [InviteName in string]: MaybeHas< + Omit< + NewInviteInput, + | "communityId" + | "lastModifiedBy" + | "pubFormSlugs" + | "stageFormSlugs" + | "communityFormSlugs" + >, + "token" + > & { + pubFormSlugs?: (keyof FI)[]; + stageFormSlugs?: (keyof FI)[]; + communityFormSlugs?: (keyof FI)[]; + }; +}; + type CreatePubRecursiveInput = Parameters[0]; const makePubInitializerMatchCreatePubRecursiveInput = < @@ -477,6 +511,11 @@ type FormsByName> = { elements: (F[K]["elements"][number] & FormElements)[]; }; }; + +export type InvitesByName> = { + [InviteName in keyof II]: Invite & { inviteToken: string }; +}; + // =================================== /** @@ -492,6 +531,7 @@ export async function seedCommunity< const PI extends PubInitializer[], const F extends FormInitializer, const AI extends ApiTokenInitializer, + const II extends InviteInitializer, >( props: { /** @@ -694,6 +734,7 @@ export async function seedCommunity< pubs?: PI; forms?: F; apiTokens?: AI; + invites?: II; }, options?: { /** @@ -1163,7 +1204,6 @@ export async function seedCommunity< logger.info(`${createdCommunity.name}: Successfully created ${createdActions.length} actions`); - const apiTokens = Object.entries(props.apiTokens ?? {}); const possibleRules = consolidatedStages.flatMap( (stage, idx) => stage.rules?.map((rule) => ({ @@ -1202,6 +1242,7 @@ export async function seedCommunity< logger.info(`${createdCommunity.name}: Successfully created ${createdRules.length} rules`); + const apiTokens = Object.entries(props.apiTokens ?? {}); const createdApiTokens = Object.fromEntries( await Promise.all( apiTokens.map(async ([tokenName, tokenInput]) => { @@ -1258,6 +1299,88 @@ export async function seedCommunity< [TokenName in keyof NonNullable]: string; }; + const createdInvites = Object.fromEntries( + await Promise.all( + Object.entries(props.invites ?? {}).map(async ([inviteName, inviteInput]) => { + const { + token: rawToken, + pubFormSlugs, + stageFormSlugs, + communityFormSlugs, + ...rest + } = inviteInput; + + let token: string; + let id: InvitesId; + if (rawToken) { + const res = InviteService.parseInviteToken(rawToken); + token = res.token; + id = res.id; + } else { + id = crypto.randomUUID() as InvitesId; + token = generateToken(); + } + + const pFormSlugs = pubFormSlugs + ? pubFormSlugs.map((slug) => { + const form = formsByName[slug]; + if (!form) { + throw new Error(`Form ${slug as string} not found`); + } + return form.slug; + }) + : undefined; + + const sFormSlugs = stageFormSlugs + ? stageFormSlugs.map((slug) => { + const form = formsByName[slug]; + if (!form) { + throw new Error(`Form ${slug as string} not found`); + } + return form.slug; + }) + : undefined; + + const cfSlugs = communityFormSlugs + ? communityFormSlugs.map((slug) => { + const form = formsByName[slug]; + if (!form) { + throw new Error(`Form ${slug as string} not found`); + } + return form.slug; + }) + : undefined; + + const rawInput = { + id, + token, + communityId, + lastModifiedBy: createLastModifiedBy("system"), + pubFormSlugs: pFormSlugs ?? null, + stageFormSlugs: sFormSlugs ?? null, + communityFormSlugs: cfSlugs ?? null, + invitedByUserId: inviteInput.invitedByActionRunId + ? null + : expect( + createdMembers.find((member) => member.role === MemberRole.admin) + ?.userId, + "You need to create an admin member in the seed if you dont want to set the invitee manually." + ), + ...rest, + }; + + const input = newInviteSchema.parse(rawInput); + + const invite = await InviteService._createInvite(input, trx); + + return [ + inviteName, + { ...invite, inviteToken: InviteService.createInviteToken(invite) }, + ]; + }) + ) + ) as InvitesByName; + logger.info(`${createdCommunity.name}: Successfully seeded community`); return { @@ -1272,5 +1395,6 @@ export async function seedCommunity< actions: createdActions, forms: formsByName, apiTokens: createdApiTokens, + invites: createdInvites, }; } diff --git a/core/prisma/seeds/invite-test-community.ts b/core/prisma/seeds/invite-test-community.ts new file mode 100644 index 0000000000..75df74bd87 --- /dev/null +++ b/core/prisma/seeds/invite-test-community.ts @@ -0,0 +1,421 @@ +import crypto from "crypto"; + +import { faker } from "@faker-js/faker"; + +import type { InvitesId, PubsId, UsersId } from "db/public"; +import { + Action, + CoreSchemaType, + ElementType, + InputComponent, + InviteStatus, + MemberRole, +} from "db/public"; + +import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; +import { createSeed } from "~/prisma/seed/createSeed"; +import { seedCommunity } from "~/prisma/seed/seedCommunity"; + +const ACTION_NAME_USER = "Invite evaluator (user)"; +const ACTION_NAME_EMAIL = "Invite evaluator (email)"; + +const firstName1 = faker.person.firstName(); +const lastName1 = faker.person.lastName(); +const email1 = `${firstName1}@example.com`; + +const firstName2 = faker.person.firstName(); +const lastName2 = faker.person.lastName(); +const email2 = `${firstName2}@example.com`; + +const firstName3 = faker.person.firstName(); +const lastName3 = faker.person.lastName(); +const email3 = `${firstName3}@example.com`; + +const firstName4 = faker.person.firstName(); +const lastName4 = faker.person.lastName(); +const email4 = `${firstName4}@example.com`; + +const firstName5 = faker.person.firstName(); +const lastName5 = faker.person.lastName(); +const email5 = `${firstName5}@example.com`; + +const firstName6 = faker.person.firstName(); +const lastName6 = faker.person.lastName(); +const email6 = `${firstName6}@example.com`; + +const evalSlug = "evaluation"; +const communityFormSlug = "community-form"; + +const invitedUserId = crypto.randomUUID() as UsersId; + +const pub1Id = crypto.randomUUID() as PubsId; + +const createTokenOfOnly = (letter: string, index: number = 0): string => { + const uuidParts = [ + letter.repeat(8), + letter.repeat(4), + `${4}${letter.repeat(3)}`, + letter.repeat(4), + index.toString().repeat(12), + ]; + + const uuid = uuidParts.join("-"); + const randomPart = `${letter.repeat(8)}${index.toString().repeat(8)}`; + + return `${uuid}.${randomPart}`; +}; + +const seed = createSeed({ + community: { + name: `test community`, + slug: "test-community", + }, + pubFields: { + Title: { + schemaName: CoreSchemaType.String, + }, + Content: { + schemaName: CoreSchemaType.String, + }, + Email: { + schemaName: CoreSchemaType.Email, + }, + }, + users: { + admin: { + role: MemberRole.admin, + password: "password", + }, + user2: { + role: MemberRole.contributor, + password: "xxxx-xxxx", + }, + }, + pubTypes: { + Submission: { + Title: { isTitle: true }, + Content: { isTitle: false }, + }, + Evaluation: { + Title: { isTitle: true }, + Content: { isTitle: false }, + Email: { isTitle: false }, + }, + }, + stages: { + Evaluating: { + actions: { + [ACTION_NAME_USER]: { + action: Action.email, + config: { + subject: "Hello", + body: "Greetings", + recipientEmail: email1, + }, + }, + [ACTION_NAME_EMAIL]: { + action: Action.email, + config: { + subject: "HELLO REVIEW OUR STUFF PLEASE... privately", + recipientEmail: email2, + body: `You are invited to fill in a form.\n\n\n\n:link{form="${evalSlug}" text="Wow, a great form!"}\n\n`, + }, + }, + }, + }, + }, + pubs: [ + { + id: pub1Id, + pubType: "Submission", + values: { + Title: "The Activity of Snails", + }, + stage: "Evaluating", + }, + { + pubType: "Submission", + values: { + Title: "Do not let anyone edit me", + }, + stage: "Evaluating", + }, + { + pubType: "Evaluation", + values: {}, + stage: "Evaluating", + }, + ], + forms: { + Evaluation: { + slug: evalSlug, + pubType: "Evaluation", + elements: [ + { + type: ElementType.pubfield, + field: "Title", + component: InputComponent.textInput, + config: { + label: "Title", + }, + }, + { + type: ElementType.pubfield, + field: "Content", + component: InputComponent.textArea, + config: { + label: "Content", + }, + }, + { + type: ElementType.pubfield, + field: "Email", + component: InputComponent.textInput, + config: { + label: "Email", + }, + }, + ], + }, + CommunityForm: { + slug: communityFormSlug, + pubType: "Submission", + elements: [ + { + type: ElementType.pubfield, + field: "Title", + component: InputComponent.textInput, + config: { + label: "Form Title", + }, + }, + ], + }, + }, + invites: { + expiredEmailInvite: { + token: createTokenOfOnly("a", 0), + provisionalUser: { + email: email1, + firstName: firstName1, + lastName: lastName1, + }, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() - 1000 * 60 * 60 * 24), + }, + acceptedEmailInvite: { + token: createTokenOfOnly("a", 1), + provisionalUser: { + email: email3, + firstName: firstName3, + lastName: lastName3, + }, + status: InviteStatus.accepted, + lastSentAt: new Date(), + }, + rejectedEmailInvite: { + token: createTokenOfOnly("a", 2), + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }, + status: InviteStatus.rejected, + lastSentAt: new Date(), + }, + revokedEmailInvite: { + token: createTokenOfOnly("a", 3), + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }, + status: InviteStatus.revoked, + lastSentAt: new Date(), + }, + createdEmailInvite: { + token: createTokenOfOnly("a", 4), + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }, + status: InviteStatus.created, + }, + happyPathEmailInvite: { + token: createTokenOfOnly("b", 5), + provisionalUser: { + email: email2, + firstName: firstName2, + lastName: lastName2, + }, + communityFormSlugs: ["Evaluation"], + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + }, + expiredUserInvite: { + token: createTokenOfOnly("a", 6), + userId: invitedUserId, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() - 1000 * 60 * 60 * 24), + }, + acceptedUserInvite: { + token: createTokenOfOnly("a", 7), + userId: invitedUserId, + status: InviteStatus.accepted, + lastSentAt: new Date(), + }, + rejectedUserInvite: { + token: createTokenOfOnly("a", 8), + userId: invitedUserId, + status: InviteStatus.rejected, + lastSentAt: new Date(), + }, + revokedUserInvite: { + token: createTokenOfOnly("a", 9), + userId: invitedUserId, + status: InviteStatus.revoked, + lastSentAt: new Date(), + }, + createdUserInvite: { + token: createTokenOfOnly("c", 0), + userId: invitedUserId, + status: InviteStatus.created, + }, + happyPathUserInvite: { + token: createTokenOfOnly("b", 1), + userId: invitedUserId, + communityFormSlugs: ["Evaluation"], + status: InviteStatus.pending, + lastSentAt: new Date(), + }, + rejectEmailInvite: { + token: createTokenOfOnly("b", 3), + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: firstName2, + lastName: lastName2, + }, + pubId: pub1Id, + pubFormSlugs: ["Evaluation"], + pubRole: MemberRole.contributor, + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + + pubLevelFormInvite: { + token: createTokenOfOnly("b", 4), + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }, + pubId: pub1Id, + pubFormSlugs: ["CommunityForm"], + pubRole: MemberRole.contributor, + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + adminRoleInvite: { + token: createTokenOfOnly("b", 6), + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }, + communityRole: MemberRole.admin, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + multipleInvite1: { + token: createTokenOfOnly("b", 7), + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + }, + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + multipleInvite2: { + token: createTokenOfOnly("b", 8), + provisionalUser: { + email: email6, + firstName: firstName6, + lastName: lastName6, + }, + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + nearlyExpiredInvite: { + token: createTokenOfOnly("b", 9), + provisionalUser: { + email: `${faker.person.firstName()}@example.com`, + firstName: firstName1, + lastName: lastName1, + }, + communityRole: MemberRole.contributor, + status: InviteStatus.pending, + lastSentAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 10), + }, + }, +}); + +const seed2 = createSeed({ + community: { + name: `test community 2`, + slug: "test-community-2", + }, + users: { + invitee1: { + id: invitedUserId, + role: MemberRole.admin, + password: "password", + email: "invited@user.com", + }, + }, +}); + +let community: CommunitySeedOutput; +let community2: CommunitySeedOutput; + +const createInviteUrl = (inviteBasePath: string, inviteToken: string, redirectTo: string) => { + const inviteUrl = `${inviteBasePath}?invite=${inviteToken}&redirectTo=${redirectTo}`; + return inviteUrl; +}; + +export async function setupInviteTestCommunity() { + // needs to be first bc invites in community1 refer to users in community2 + community2 = await seedCommunity(seed2, { + randomSlug: false, + }); + community = await seedCommunity(seed, { + randomSlug: false, + }); + + const inviteBasePath = `/c/${community.community.slug}/public/invite`; + + for (const [inviteName, invite] of Object.entries(community.invites)) { + // eslint-disable-next-line no-console + console.log( + `${inviteName}:\n\n ${createInviteUrl( + inviteBasePath, + invite.inviteToken, + `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill` + )}` + ); + } +} diff --git a/core/prisma/seeds/starter.ts b/core/prisma/seeds/starter.ts index fc9e8aaf2a..c9a7cc892b 100644 --- a/core/prisma/seeds/starter.ts +++ b/core/prisma/seeds/starter.ts @@ -109,9 +109,10 @@ export async function seedStarter(communityId?: CommunitiesId) { }, ], forms: { - Review: { + "Public Review": { access: FormAccessType.public, pubType: "Evaluation", + slug: "public-review", elements: [ { type: ElementType.structural, @@ -155,17 +156,75 @@ export async function seedStarter(communityId?: CommunitiesId) { }, ], }, + "Private Review": { + slug: "private-review", + access: FormAccessType.private, + pubType: "Evaluation", + elements: [ + { + type: ElementType.structural, + element: StructuralFormElement.p, + content: `# Review\n\n Thank you for agreeing to review this Pub, please do not be a meany bobeeny.`, + }, + { + field: "Title", + type: ElementType.pubfield, + component: InputComponent.textInput, + config: { + label: "Title", + maxLength: 255, + help: "Give your review a snazzy title.", + }, + }, + { + field: "Content", + type: ElementType.pubfield, + component: InputComponent.richText, + config: { + label: "Content", + help: "Enter your review here", + }, + }, + { + field: "File", + type: ElementType.pubfield, + component: InputComponent.fileUpload, + config: { + label: "Attachment", + help: "Please attach the file for your review here.", + }, + }, + { + type: ElementType.button, + content: `Go see your pubs :link{page='currentPub' text='here'}`, + label: "Submit", + stage: "Published", + }, + ], + }, }, stages: { Draft: { members: { new: MemberRole.contributor }, actions: { - "Send Review email": { + "Log Review": { + action: Action.log, + config: {}, + }, + "Send Public Review email": { action: Action.email, config: { subject: "Hello, :recipientName! Please review this draft!", recipient: memberId, - body: `You are invited to fill in a form.\n\n\n\n:link{form="review"}\n\nCurrent time: :value{field='starter:published-at'}`, + body: `You are invited to fill in a form.\n\n\n\n:link{form="public-review" text="I've never been so excited about a form"}\n\nCurrent time: :value{field='starter:published-at'}`, + }, + }, + "Send Private Review email": { + action: Action.email, + config: { + subject: "HELLO REVIEW OUR STUFF PLEASE... privately", + recipientEmail: "james@jimothy.org", + body: `You are invited to fill in a form.\n\n\n\n:link{form="private-review" text="Wow, a great form!"}\n\nCurrent time: :value{field='starter:published-at'}`, }, }, }, diff --git a/docs/content/development/authentication/invites.mdx b/docs/content/development/authentication/invites.mdx new file mode 100644 index 0000000000..9866cd9fa2 --- /dev/null +++ b/docs/content/development/authentication/invites.mdx @@ -0,0 +1,55 @@ +# Invites + +Users can be invited to join a community, stage, or pub. This corresponds to creating a new row in the `invites` table. + +Invites (as of writing 2025-04-30) + +- Are for adding either new or existing users to a community +- Grant users new roles on acceptance +- Are always coupled to a user. If an invitee does not yet have an account, we will create a new account with `isProvisional` set to true. Once the user has + accepted the invite and finished signup, the account will be marked as `isProvisional` set to false. + +## Statuses + +Invites have the following statuses: + +| Status | Description | Can be accepted? | +| ----------- | --------------------------------------------------------- | ---------------- | +| `created` | The invite has been created but not yet sent | No | +| `pending` | The invite has been sent but not yet accepted or rejected | Yes | +| `accepted` | The invite has been accepted but signup not completed | Yes | +| `completed` | The invite has been accepted and signup completed | No | +| `rejected` | The invite has been rejected by the invitee | No | +| `revoked` | The invite has been revoked by the inviter | No | + +## Email invite flow + +```mermaid +flowchart LR + A((Email With Invite Link)) ---> B[Invite Page] + + B --->|No token| C((Invalid State View, no continuation possible)) + B --->|Invalid token| C + B --->|Expired| C + B --->|Already accepted| C + B --->|Rejected| C + B --->|Revoked| C + B --->|Not ready| C + + B -->|Wrong user logged in| J((Wrong User View)) + B -->|Not logged in, existing user invite| L((Login/Reject View)) + B -->|Valid invite, user logged in| M((Accept/Reject View)) + B -->|Not logged in, new user invite| M + M -->|Not logged in, new user invite, accept| N[Signup Page] + + J -->|Logout| B + L -->|Click login| O[Login Page] + + N -->|Submit with wrong email| N + N -->|Submit with correct email| P[Form Page] + O -->|Login as invited user| B + + M -->|Valid user, valid invite, accept| P + M -->|Reject| Q((Reject Confirmation)) + Q -->|Confirm| R((Rejection Completed)) +``` diff --git a/packages/db/src/public.ts b/packages/db/src/public.ts index 5e421138a1..803892574c 100644 --- a/packages/db/src/public.ts +++ b/packages/db/src/public.ts @@ -20,6 +20,9 @@ export * from "./public/FormElements"; export * from "./public/FormElementToPubType"; export * from "./public/Forms"; export * from "./public/InputComponent"; +export * from "./public/Invites"; +export * from "./public/InviteForms"; +export * from "./public/InviteStatus"; export * from "./public/MemberGroups"; export * from "./public/MemberGroupToUser"; export * from "./public/MemberRole"; diff --git a/packages/db/src/public/InviteForms.ts b/packages/db/src/public/InviteForms.ts new file mode 100644 index 0000000000..a7f982e611 --- /dev/null +++ b/packages/db/src/public/InviteForms.ts @@ -0,0 +1,61 @@ +import type { ColumnType, Insertable, Selectable, Updateable } from "kysely"; + +import { z } from "zod"; + +import type { FormsId } from "./Forms"; +import type { InvitesId } from "./Invites"; +import type { MembershipType } from "./MembershipType"; +import { formsIdSchema } from "./Forms"; +import { invitesIdSchema } from "./Invites"; +import { membershipTypeSchema } from "./MembershipType"; + +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +/** Identifier type for public.invite_forms */ +export type InviteFormsType = MembershipType; + +/** Represents the table public.invite_forms */ +export interface InviteFormsTable { + inviteId: ColumnType; + + formId: ColumnType; + + type: ColumnType; + + createdAt: ColumnType; + + updatedAt: ColumnType; +} + +export type InviteForms = Selectable; + +export type NewInviteForms = Insertable; + +export type InviteFormsUpdate = Updateable; + +export const inviteFormsTypeSchema = membershipTypeSchema as unknown as z.Schema; + +export const inviteFormsSchema = z.object({ + inviteId: invitesIdSchema, + formId: formsIdSchema, + type: inviteFormsTypeSchema, + createdAt: z.date(), + updatedAt: z.date(), +}); + +export const inviteFormsInitializerSchema = z.object({ + inviteId: invitesIdSchema, + formId: formsIdSchema, + type: inviteFormsTypeSchema, + createdAt: z.date().optional(), + updatedAt: z.date().optional(), +}); + +export const inviteFormsMutatorSchema = z.object({ + inviteId: invitesIdSchema.optional(), + formId: formsIdSchema.optional(), + type: inviteFormsTypeSchema.optional(), + createdAt: z.date().optional(), + updatedAt: z.date().optional(), +}); diff --git a/packages/db/src/public/InviteStatus.ts b/packages/db/src/public/InviteStatus.ts new file mode 100644 index 0000000000..fa35a59b4d --- /dev/null +++ b/packages/db/src/public/InviteStatus.ts @@ -0,0 +1,26 @@ +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +import { z } from "zod"; + +/** + * Represents the enum public.InviteStatus + * Status of an invite + * @property created - The invite has been created, but not yet sent + * @property pending - The invite has been sent, but not yet accepted + * @property accepted - The invite has been accepted, but the relevant signup step has not been completed + * @property completed - The invite has been accepted, and the relevant signup step has been completed + * @property rejected - The invite has been rejected + * @property revoked - The invite has been revoked by the user who created it, or by a sufficient authority + */ +export enum InviteStatus { + created = "created", + pending = "pending", + accepted = "accepted", + completed = "completed", + rejected = "rejected", + revoked = "revoked", +} + +/** Zod schema for InviteStatus */ +export const inviteStatusSchema = z.nativeEnum(InviteStatus); diff --git a/packages/db/src/public/InviteToForms.ts b/packages/db/src/public/InviteToForms.ts new file mode 100644 index 0000000000..26a0251d35 --- /dev/null +++ b/packages/db/src/public/InviteToForms.ts @@ -0,0 +1,47 @@ +import type { ColumnType, Insertable, Selectable, Updateable } from "kysely"; + +import { z } from "zod"; + +import type { FormsId } from "./Forms"; +import type { InvitesId } from "./Invites"; +import type { MembershipType } from "./MembershipType"; +import { formsIdSchema } from "./Forms"; +import { invitesIdSchema } from "./Invites"; +import { membershipTypeSchema } from "./MembershipType"; + +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +/** Represents the table public.invite_to_forms */ +export interface InviteToFormsTable { + formId: ColumnType; + + inviteId: ColumnType; + + /** The type of object that the form is to be associated with\ncan technically be Form, but that does not make sense */ + type: ColumnType; +} + +export type InviteToForms = Selectable; + +export type NewInviteToForms = Insertable; + +export type InviteToFormsUpdate = Updateable; + +export const inviteToFormsSchema = z.object({ + formId: formsIdSchema, + inviteId: invitesIdSchema, + type: membershipTypeSchema, +}); + +export const inviteToFormsInitializerSchema = z.object({ + formId: formsIdSchema, + inviteId: invitesIdSchema, + type: membershipTypeSchema, +}); + +export const inviteToFormsMutatorSchema = z.object({ + formId: formsIdSchema.optional(), + inviteId: invitesIdSchema.optional(), + type: membershipTypeSchema.optional(), +}); diff --git a/packages/db/src/public/Invites.ts b/packages/db/src/public/Invites.ts new file mode 100644 index 0000000000..c22eab8407 --- /dev/null +++ b/packages/db/src/public/Invites.ts @@ -0,0 +1,136 @@ +import type { ColumnType, Insertable, Selectable, Updateable } from "kysely"; + +import { z } from "zod"; + +import type { LastModifiedBy } from "../types"; +import type { ActionRunsId } from "./ActionRuns"; +import type { CommunitiesId } from "./Communities"; +import type { InviteStatus } from "./InviteStatus"; +import type { MemberRole } from "./MemberRole"; +import type { PubsId } from "./Pubs"; +import type { StagesId } from "./Stages"; +import type { UsersId } from "./Users"; +import { actionRunsIdSchema } from "./ActionRuns"; +import { communitiesIdSchema } from "./Communities"; +import { inviteStatusSchema } from "./InviteStatus"; +import { memberRoleSchema } from "./MemberRole"; +import { modifiedByTypeSchema } from "./ModifiedByType"; +import { pubsIdSchema } from "./Pubs"; +import { stagesIdSchema } from "./Stages"; +import { usersIdSchema } from "./Users"; + +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +/** Identifier type for public.invites */ +export type InvitesId = string & { __brand: "InvitesId" }; + +/** Represents the table public.invites */ +export interface InvitesTable { + id: ColumnType; + + userId: ColumnType; + + token: ColumnType; + + expiresAt: ColumnType; + + createdAt: ColumnType; + + updatedAt: ColumnType; + + communityId: ColumnType; + + communityRole: ColumnType; + + pubId: ColumnType; + + pubRole: ColumnType; + + stageId: ColumnType; + + stageRole: ColumnType; + + message: ColumnType; + + lastSentAt: ColumnType; + + status: ColumnType; + + invitedByUserId: ColumnType; + + invitedByActionRunId: ColumnType; + + lastModifiedBy: ColumnType; +} + +export type Invites = Selectable; + +export type NewInvites = Insertable; + +export type InvitesUpdate = Updateable; + +export const invitesIdSchema = z.string().uuid() as unknown as z.Schema; + +export const invitesSchema = z.object({ + id: invitesIdSchema, + userId: usersIdSchema, + token: z.string(), + expiresAt: z.date(), + createdAt: z.date(), + updatedAt: z.date(), + communityId: communitiesIdSchema, + communityRole: memberRoleSchema, + pubId: pubsIdSchema.nullable(), + pubRole: memberRoleSchema.nullable(), + stageId: stagesIdSchema.nullable(), + stageRole: memberRoleSchema.nullable(), + message: z.string().nullable(), + lastSentAt: z.date().nullable(), + status: inviteStatusSchema, + invitedByUserId: usersIdSchema.nullable(), + invitedByActionRunId: actionRunsIdSchema.nullable(), + lastModifiedBy: modifiedByTypeSchema, +}); + +export const invitesInitializerSchema = z.object({ + id: invitesIdSchema.optional(), + userId: usersIdSchema, + token: z.string(), + expiresAt: z.date(), + createdAt: z.date().optional(), + updatedAt: z.date().optional(), + communityId: communitiesIdSchema, + communityRole: memberRoleSchema.optional(), + pubId: pubsIdSchema.optional().nullable(), + pubRole: memberRoleSchema.optional().nullable(), + stageId: stagesIdSchema.optional().nullable(), + stageRole: memberRoleSchema.optional().nullable(), + message: z.string().optional().nullable(), + lastSentAt: z.date().optional().nullable(), + status: inviteStatusSchema.optional(), + invitedByUserId: usersIdSchema.optional().nullable(), + invitedByActionRunId: actionRunsIdSchema.optional().nullable(), + lastModifiedBy: modifiedByTypeSchema, +}); + +export const invitesMutatorSchema = z.object({ + id: invitesIdSchema.optional(), + userId: usersIdSchema.optional(), + token: z.string().optional(), + expiresAt: z.date().optional(), + createdAt: z.date().optional(), + updatedAt: z.date().optional(), + communityId: communitiesIdSchema.optional(), + communityRole: memberRoleSchema.optional(), + pubId: pubsIdSchema.optional().nullable(), + pubRole: memberRoleSchema.optional().nullable(), + stageId: stagesIdSchema.optional().nullable(), + stageRole: memberRoleSchema.optional().nullable(), + message: z.string().optional().nullable(), + lastSentAt: z.date().optional().nullable(), + status: inviteStatusSchema.optional(), + invitedByUserId: usersIdSchema.optional().nullable(), + invitedByActionRunId: actionRunsIdSchema.optional().nullable(), + lastModifiedBy: modifiedByTypeSchema.optional(), +}); diff --git a/packages/db/src/public/InvitesHistory.ts b/packages/db/src/public/InvitesHistory.ts new file mode 100644 index 0000000000..75e1523c2f --- /dev/null +++ b/packages/db/src/public/InvitesHistory.ts @@ -0,0 +1,93 @@ +import type { ColumnType, Insertable, Selectable, Updateable } from "kysely"; + +import { z } from "zod"; + +import type { ActionRunsId } from "./ActionRuns"; +import type { ApiAccessTokensId } from "./ApiAccessTokens"; +import type { Invites } from "./Invites"; +import type { OperationType } from "./OperationType"; +import type { UsersId } from "./Users"; +import { actionRunsIdSchema } from "./ActionRuns"; +import { apiAccessTokensIdSchema } from "./ApiAccessTokens"; +import { operationTypeSchema } from "./OperationType"; +import { usersIdSchema } from "./Users"; + +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +/** Identifier type for public.invites_history */ +export type InvitesHistoryId = string & { __brand: "InvitesHistoryId" }; + +/** Represents the table public.invites_history */ +export interface InvitesHistoryTable { + id: ColumnType; + + createdAt: ColumnType; + + operationType: ColumnType; + + oldRowData: ColumnType; + + newRowData: ColumnType; + + inviteId: ColumnType; + + userId: ColumnType; + + apiAccessTokenId: ColumnType< + ApiAccessTokensId | null, + ApiAccessTokensId | null, + ApiAccessTokensId | null + >; + + actionRunId: ColumnType; + + other: ColumnType; +} + +export type InvitesHistory = Selectable; + +export type NewInvitesHistory = Insertable; + +export type InvitesHistoryUpdate = Updateable; + +export const invitesHistoryIdSchema = z.string().uuid() as unknown as z.Schema; + +export const invitesHistorySchema = z.object({ + id: invitesHistoryIdSchema, + createdAt: z.date(), + operationType: operationTypeSchema, + oldRowData: z.unknown().nullable(), + newRowData: z.unknown().nullable(), + inviteId: z.string().nullable(), + userId: usersIdSchema.nullable(), + apiAccessTokenId: apiAccessTokensIdSchema.nullable(), + actionRunId: actionRunsIdSchema.nullable(), + other: z.string().nullable(), +}); + +export const invitesHistoryInitializerSchema = z.object({ + id: invitesHistoryIdSchema.optional(), + createdAt: z.date().optional(), + operationType: operationTypeSchema, + oldRowData: z.unknown().optional().nullable(), + newRowData: z.unknown().optional().nullable(), + inviteId: z.string().optional().nullable(), + userId: usersIdSchema.optional().nullable(), + apiAccessTokenId: apiAccessTokensIdSchema.optional().nullable(), + actionRunId: actionRunsIdSchema.optional().nullable(), + other: z.string().optional().nullable(), +}); + +export const invitesHistoryMutatorSchema = z.object({ + id: invitesHistoryIdSchema.optional(), + createdAt: z.date().optional(), + operationType: operationTypeSchema.optional(), + oldRowData: z.unknown().optional().nullable(), + newRowData: z.unknown().optional().nullable(), + inviteId: z.string().optional().nullable(), + userId: usersIdSchema.optional().nullable(), + apiAccessTokenId: apiAccessTokensIdSchema.optional().nullable(), + actionRunId: actionRunsIdSchema.optional().nullable(), + other: z.string().optional().nullable(), +}); diff --git a/packages/db/src/public/PublicSchema.ts b/packages/db/src/public/PublicSchema.ts index 9a2024d224..b39c966568 100644 --- a/packages/db/src/public/PublicSchema.ts +++ b/packages/db/src/public/PublicSchema.ts @@ -12,6 +12,9 @@ import type { CommunityMembershipsTable } from "./CommunityMemberships"; import type { FormElementsTable } from "./FormElements"; import type { FormElementToPubTypeTable } from "./FormElementToPubType"; import type { FormsTable } from "./Forms"; +import type { InviteFormsTable } from "./InviteForms"; +import type { InvitesTable } from "./Invites"; +import type { InvitesHistoryTable } from "./InvitesHistory"; import type { MemberGroupsTable } from "./MemberGroups"; import type { MemberGroupToUserTable } from "./MemberGroupToUser"; import type { MembershipCapabilitiesTable } from "./MembershipCapabilities"; @@ -92,4 +95,10 @@ export interface PublicSchema { pub_values_history: PubValuesHistoryTable; _FormElementToPubType: FormElementToPubTypeTable; + + invites: InvitesTable; + + invite_forms: InviteFormsTable; + + invites_history: InvitesHistoryTable; } diff --git a/packages/db/src/public/Users.ts b/packages/db/src/public/Users.ts index ca4a98d57d..06391493cd 100644 --- a/packages/db/src/public/Users.ts +++ b/packages/db/src/public/Users.ts @@ -33,6 +33,8 @@ export interface UsersTable { passwordHash: ColumnType; isVerified: ColumnType; + + isProvisional: ColumnType; } export type Users = Selectable; @@ -56,6 +58,7 @@ export const usersSchema = z.object({ isSuperAdmin: z.boolean(), passwordHash: z.string().nullable(), isVerified: z.boolean(), + isProvisional: z.boolean(), }); export const usersInitializerSchema = z.object({ @@ -71,6 +74,7 @@ export const usersInitializerSchema = z.object({ isSuperAdmin: z.boolean().optional(), passwordHash: z.string().optional().nullable(), isVerified: z.boolean().optional(), + isProvisional: z.boolean().optional(), }); export const usersMutatorSchema = z.object({ @@ -86,4 +90,5 @@ export const usersMutatorSchema = z.object({ isSuperAdmin: z.boolean().optional(), passwordHash: z.string().optional().nullable(), isVerified: z.boolean().optional(), + isProvisional: z.boolean().optional(), }); diff --git a/packages/db/src/table-names.ts b/packages/db/src/table-names.ts index 328d397ec5..dc2153e1cf 100644 --- a/packages/db/src/table-names.ts +++ b/packages/db/src/table-names.ts @@ -18,6 +18,9 @@ export const databaseTableNames = [ "community_memberships", "form_elements", "forms", + "invite_forms", + "invites", + "invites_history", "member_groups", "membership_capabilities", "move_constraint", @@ -1045,6 +1048,292 @@ export const databaseTables = [ }, ], }, + { + name: "invite_forms", + isView: false, + schema: "public", + columns: [ + { + name: "inviteId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "formId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "type", + dataType: "MembershipType", + dataTypeSchema: "public", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "createdAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "updatedAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + ], + }, + { + name: "invites", + isView: false, + schema: "public", + columns: [ + { + name: "id", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "userId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "token", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "expiresAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "createdAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "updatedAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "communityId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "communityRole", + dataType: "MemberRole", + dataTypeSchema: "public", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "pubId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "pubRole", + dataType: "MemberRole", + dataTypeSchema: "public", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "stageId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "stageRole", + dataType: "MemberRole", + dataTypeSchema: "public", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "message", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "lastSentAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "status", + dataType: "InviteStatus", + dataTypeSchema: "public", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "invitedByUserId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "invitedByActionRunId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "lastModifiedBy", + dataType: "modified_by_type", + dataTypeSchema: "public", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + comment: "@type(LastModifiedBy, '../types', true, false, true)", + }, + ], + }, + { + name: "invites_history", + isView: false, + schema: "public", + columns: [ + { + name: "id", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "createdAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "operationType", + dataType: "OperationType", + dataTypeSchema: "public", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "oldRowData", + dataType: "jsonb", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "newRowData", + dataType: "jsonb", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "inviteId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "userId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "apiAccessTokenId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "actionRunId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "other", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + ], + }, { name: "member_groups", isView: false, @@ -1952,6 +2241,14 @@ export const databaseTables = [ isAutoIncrementing: false, hasDefaultValue: true, }, + { + name: "isProvisional", + dataType: "bool", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, ], }, ]; diff --git a/packages/db/src/types/Invite.ts b/packages/db/src/types/Invite.ts new file mode 100644 index 0000000000..60e6c64163 --- /dev/null +++ b/packages/db/src/types/Invite.ts @@ -0,0 +1,263 @@ +import { z } from "zod"; + +import type { Invites } from "../public"; +import { + actionRunsIdSchema, + communitiesIdSchema, + formsIdSchema, + invitesIdSchema, + MemberRole, + memberRoleSchema, + pubsIdSchema, + pubTypesIdSchema, + stagesIdSchema, + usersIdSchema, + usersSchema, +} from "../public"; +import { InviteStatus } from "../public/InviteStatus"; +import { lastModifiedBySchema } from "./LastModifiedBy"; + +export const inviteSchema = z + .object({ + id: invitesIdSchema, + token: z.string(), + /** + * The date and time that the invite expires. + * Note: this will not change the status of the invite + * TODO: create some kind of cron-job that changes/deletes the invite on token expiration + */ + expiresAt: z.date(), + createdAt: z.date(), + updatedAt: z.date(), + communityId: communitiesIdSchema, + community: z.object({ + id: communitiesIdSchema, + slug: z.string(), + avatar: z.string().nullable(), + name: z.string(), + }), + communityRole: memberRoleSchema, + /** + * The form that is used to invite the user to the community. + * This is used to allow a user to fill out a form to join the community. + * This is optional because the user may not need access to a form + */ + communityFormIds: formsIdSchema.array().nullable(), + /** + * The message that is sent in the invite. + * This is optional because the user may not need a message. + */ + message: z.string().nullable(), + lastModifiedBy: lastModifiedBySchema, + /** + * The to-be-invited user + */ + userId: usersIdSchema, + user: usersSchema.omit({ + passwordHash: true, + }), + }) + + .and( + // PubOrStage + z.union([ + z.object({ + pubId: pubsIdSchema, + pub: z.object({ + id: pubsIdSchema, + title: z.string().nullable(), + pubType: z.object({ + id: pubTypesIdSchema, + name: z.string(), + }), + }), + pubRole: memberRoleSchema, + pubFormIds: formsIdSchema.array().nullable(), + stageId: z.null(), + stage: z.null(), + stageRole: z.null(), + stageFormIds: formsIdSchema.array().nullable(), + }), + z.object({ + pubId: z.null(), + pub: z.null(), + pubRole: z.null(), + pubFormIds: formsIdSchema.array().nullable(), + stageId: stagesIdSchema, + stage: z.object({ + id: stagesIdSchema, + name: z.string(), + }), + stageRole: memberRoleSchema, + stageFormIds: formsIdSchema.array().nullable(), + }), + z.object({ + pubId: z.null(), + pub: z.null(), + pubRole: z.null(), + pubFormIds: formsIdSchema.array().nullable(), + stageId: z.null(), + stage: z.null(), + stageRole: z.null(), + stageFormIds: formsIdSchema.array().nullable(), + }), + ]) + ) + .and( + // InvitedBy + z.union([ + z.object({ + invitedByUserId: usersIdSchema, + invitedByActionRunId: z.null(), + }), + z.object({ + invitedByUserId: z.null(), + invitedByActionRunId: actionRunsIdSchema, + }), + ]) + ) + .and( + // LastSentAtStatus + z.union([ + z.object({ + /** + * The date and time that the invite was last sent. + */ + lastSentAt: z.coerce.date(), + status: z.enum([ + InviteStatus.accepted, + InviteStatus.pending, + InviteStatus.rejected, + InviteStatus.revoked, + InviteStatus.completed, + ]), + }), + z.object({ + /** + * Null, bc the invite has not been sent yet + */ + lastSentAt: z.null(), + status: z.literal(InviteStatus.created), + }), + ]) + ) satisfies z.ZodType; + +type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +export type Invite = Prettify>; + +const _typeTestFunc = () => { + let invite = {} as Invite; + let invite2 = {} as Invites; + + // this should be allowed, Invite should be a subtype of Invites + invite2 = invite; +}; + +// 30 days +export const DEFAULT_INVITE_EXPIRATION_TIME = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30 * 12); + +export const newInviteSchema = z + .object({ + id: invitesIdSchema.optional(), + token: z.string(), + + expiresAt: z.date().default(DEFAULT_INVITE_EXPIRATION_TIME), + createdAt: z.date().optional(), + updatedAt: z.date().optional(), + + communityId: communitiesIdSchema, + communityRole: memberRoleSchema.default(MemberRole.contributor), + communityFormIds: formsIdSchema.array().nullish(), + communityFormSlugs: z.string().array().nullish(), + message: z.string().nullish(), + lastModifiedBy: lastModifiedBySchema, + }) + .and( + // ProvisionalUser + z.union([ + z.object({ + provisionalUser: z.null().optional(), + userId: usersIdSchema, + }), + z.object({ + provisionalUser: z.object({ + firstName: z.string(), + lastName: z.string(), + email: z.string(), + }), + userId: z.null().optional(), + }), + ]) + ) + .and( + // PubOrStage - make pubOrStageRole optional when pub/stage is set + z.union([ + z.object({ + pubId: pubsIdSchema, + pubFormIds: formsIdSchema.array().optional(), + pubFormSlugs: z.string().array().optional(), + pubRole: memberRoleSchema, + stageId: z.null().optional(), + stageFormIds: z.null().optional(), + stageFormSlugs: z.null().optional(), + stageRole: z.null().optional(), + }), + z.object({ + pubId: z.null().optional(), + pubFormIds: z.null().optional(), + pubFormSlugs: z.null().optional(), + pubRole: z.null().optional(), + stageId: stagesIdSchema, + stageFormIds: formsIdSchema.array().nullish(), + stageFormSlugs: z.string().array().nullish(), + stageRole: memberRoleSchema.optional(), + }), + z.object({ + pubId: z.null().optional(), + pubFormIds: z.null().optional(), + pubFormSlugs: z.null().optional(), + pubRole: z.null().optional(), + stageId: z.null().optional(), + stageFormIds: z.null().optional(), + stageFormSlugs: z.null().optional(), + stageRole: z.null().optional(), + }), + ]) + ) + .and( + // InvitedBy + z.union([ + z.object({ + invitedByUserId: usersIdSchema, + invitedByActionRunId: z.null().optional(), + }), + z.object({ + invitedByUserId: z.null().optional(), + invitedByActionRunId: actionRunsIdSchema, + }), + ]) + ) + .and( + z.union([ + z.object({ + lastSentAt: z.coerce.date(), + status: z.enum([ + InviteStatus.accepted, + InviteStatus.pending, + InviteStatus.rejected, + InviteStatus.revoked, + InviteStatus.completed, + ]), + }), + z.object({ + lastSentAt: z.null().optional(), + status: z.literal(InviteStatus.created).default(InviteStatus.created), + }), + ]) + ); + +export type NewInviteInput = Prettify>; +export type NewInvite = Prettify>; diff --git a/packages/db/src/types/LastModifiedBy.ts b/packages/db/src/types/LastModifiedBy.ts index dbedf1dd96..84489181e5 100644 --- a/packages/db/src/types/LastModifiedBy.ts +++ b/packages/db/src/types/LastModifiedBy.ts @@ -1,3 +1,7 @@ +import { z } from "zod"; + +import { uuidRegex } from "utils/uuid"; + import type { ActionRunsId, ApiAccessTokensId, UsersId } from "../public"; export type LastModifiedBy = `${ @@ -6,3 +10,9 @@ export type LastModifiedBy = `${ | `api-access-token:${ApiAccessTokensId}` | "unknown" | "system"}|${number}`; + +const regex = `^((user|action-run|api-access-token):${uuidRegex.source.replace(/\^|\$/g, "")}|(?:system|unknown))\|\d{13}$`; + +export const lastModifiedBySchema = z + .string() + .regex(new RegExp(regex)) as z.ZodType; diff --git a/packages/db/src/types/index.ts b/packages/db/src/types/index.ts index 44d00537d7..e64ce5f6f9 100644 --- a/packages/db/src/types/index.ts +++ b/packages/db/src/types/index.ts @@ -1,3 +1,4 @@ export * from "./ApiAccessToken"; export * from "./LastModifiedBy"; export * from "./HistoryTable"; +export * from "./Invite"; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 2b598aa5d7..07157f6715 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -2,7 +2,8 @@ "extends": "tsconfig/base.json", "compilerOptions": { "noEmit": true, - "isolatedModules": true + "isolatedModules": true, + "moduleResolution": "bundler" }, "include": ["./src", "scripts/comment-generator.ts"], "exclude": ["dist", "build", "node_modules"] diff --git a/packages/emails/src/signup-invite.tsx b/packages/emails/src/signup-invite.tsx index e9598a8f6e..51b16e13fd 100644 --- a/packages/emails/src/signup-invite.tsx +++ b/packages/emails/src/signup-invite.tsx @@ -15,27 +15,84 @@ import { Text, } from "@react-email/components"; -import type { Communities, MembershipType } from "db/public"; +import type { Communities, Forms, MembershipType, Pubs, PubTypes, Stages } from "db/public"; import { MemberRole } from "db/public"; -interface SignupInviteProps { - signupLink: string; +type SignupInvitePropsBase = { + inviteLink: string; community: Pick; - role: MemberRole; + communityRole: MemberRole; previewText?: string; - membership: { type: MembershipType; name: string }; -} - -export const SignupInvite = ({ - community: comm, - signupLink, - role, - membership, - previewText = `Join ${comm?.name} on PubPub`, -}: SignupInviteProps) => { + message?: string | React.ReactNode | null; +}; + +type SignupInviteCommunity = SignupInvitePropsBase & { + type: "community"; +}; + +type SignupInviteForm = SignupInvitePropsBase & { + type: "form"; + form: Pick; +}; + +type SignupInvitePub = SignupInvitePropsBase & { + type: "pub"; + pub: Pick & { + pubType: Pick; + }; + pubRole: MemberRole; +}; + +type SignupInviteStage = SignupInvitePropsBase & { + type: "stage"; + stage: Pick; + stageRole: MemberRole; +}; + +export type SignupInviteProps = + | SignupInviteCommunity + | SignupInviteForm + | SignupInvitePub + | SignupInviteStage; + +const roleToVerb = { + [MemberRole.admin]: "admin", + [MemberRole.editor]: "edit", + [MemberRole.contributor]: "contribute to", +} as const satisfies Record; + +const communityRoleToVerb = { + [MemberRole.admin]: "become an admin at", + [MemberRole.editor]: "become an editor at", + [MemberRole.contributor]: "join", +} as const satisfies Record; + +const inviteMessage = (invite: SignupInviteProps) => { + let extraText = ""; + if (invite.type === "stage") { + extraText = ` and ${roleToVerb[invite.stageRole]} the stage ${invite.stage.name}`; + } + + if (invite.type === "pub") { + extraText = ` and ${roleToVerb[invite.pubRole]} ${ + // todo: proper logic for articles + invite.pub.title + ? `the Pub "${invite.pub.title}"` + : `to a(n) ${invite.pub.pubType.name}` + }`; + } + + return `You've been invited to ${communityRoleToVerb[invite.communityRole]} ${invite.community.name}${extraText}.`; +}; + +const defaultPreviewText = (props: SignupInviteProps) => { + return `Join ${props.community.name} on PubPub`; +}; + +export const Invite = (props: SignupInviteProps) => { const baseUrl = process.env.PUBPUB_URL ?? ""; - const community = comm ?? { + const community = props.community ?? { name: "CrocCroc", avatar: `${baseUrl}/demo/croc.png`, slug: "croccroc", @@ -44,7 +101,7 @@ export const SignupInvite = ({ return ( - {previewText} + {props.previewText ?? defaultPreviewText(props)} @@ -61,24 +118,20 @@ export const SignupInvite = ({ Join {community.name} on PubPub - You have been invited to become{" "} - {role === MemberRole.contributor ? "a" : "an"} {role}{" "} - of the {membership.type} {membership.name} on PubPub. Click the - button below to finish your registration and join {community.name} on - PubPub. + {props.message ?? inviteMessage(props)}
or copy and paste this URL into your browser:{" "} - - {signupLink} + + {props.inviteLink}
diff --git a/packages/utils/package.json b/packages/utils/package.json index 267e9464f8..e755eeb37c 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -11,6 +11,7 @@ ".": "./dist/utils.js", "./sleep": "./dist/utils-sleep.js", "./assert": "./dist/utils-assert.js", + "./try-catch": "./dist/utils-try-catch.js", "./classnames": "./dist/utils-classnames.js", "./package.json": "./package.json" }, @@ -37,7 +38,8 @@ "url.ts", "doi.ts", "sleep.ts", - "uuid.ts" + "uuid.ts", + "try-catch.ts" ], "exports": true, "___experimentalFlags_WILL_CHANGE_IN_PATCH": { diff --git a/packages/utils/src/try-catch.ts b/packages/utils/src/try-catch.ts new file mode 100644 index 0000000000..cc8fe747d3 --- /dev/null +++ b/packages/utils/src/try-catch.ts @@ -0,0 +1,67 @@ +type Success = [null, T]; +type Failure = [E, null]; + +type Result = Success | Failure; + +/** + * Wrapper function that allows you to do `try/catch` blocks inline. + * Asynchronous version + * + * @example + * ```ts + * const [err, res] = await tryCatch(validateToken(token)); + * ``` + */ +export function tryCatch(promise: Promise): Promise>; +/** + * Wrapper function that allows you to do `try/catch` blocks inline. + * Asynchronous function version. + * + * @example + * ```ts + * const [err, res] = await tryCatch(() => validateToken(token)); + * ``` + */ +export function tryCatch(fn: () => Promise): Promise>; +/** + * Wrapper function that allows you to do `try/catch` blocks inline. + * Synchronous version. + * + * @example + * ```ts + * const [err, res] = tryCatch(() => JSON.parse("hello")); + * if(!err){ + * console.log(res); + * // ^? string + * } + * ``` + */ +export function tryCatch(fn: () => T): Result; +export function tryCatch( + promiseOrFn: Promise | (() => Promise) | (() => T) +): Promise> | Result { + if (typeof promiseOrFn !== "function" && !(promiseOrFn instanceof Promise)) { + throw new Error("Invalid input"); + } + + if (promiseOrFn instanceof Promise) { + return promiseOrFn + .then((res) => [null, res] as Success) + .catch((error) => [error as E, null] as Failure); + } + + try { + const data = promiseOrFn(); + + if (!(data instanceof Promise)) { + return [null, data] as Success; + } + + const res = data + .then((res) => [null, res] as Success) + .catch((error) => [error as E, null] as Failure); + return res; + } catch (error) { + return [error as E, null] as Failure; + } +}