diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a3cc0817..8ea9c1cb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -70,6 +70,7 @@ model Membership { siteId String role MembershipRole acceptedAt DateTime? + // Last time the user switched to this membership using the site switcher in the dashboard lastSwitchedTo DateTime @default(now()) config Json @default("{}") diff --git a/src/components/dashboard/DashboardLayout.tsx b/src/components/dashboard/DashboardLayout.tsx index ce7bf5dc..33f71d14 100644 --- a/src/components/dashboard/DashboardLayout.tsx +++ b/src/components/dashboard/DashboardLayout.tsx @@ -12,8 +12,11 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) { const router = useRouter() const subdomain = router.query.subdomain as string - const sitesResult = trpc.useQuery(["dashbaord.sitesForSwitcher"], {}) - const sites = sitesResult.data + const subscriptionsQuery = trpc.useQuery( + ["user.getSubscriptions", { canManage: true }], + {} + ) + const subscriptions = subscriptionsQuery.data const viewerResult = trpc.useQuery(["auth.viewer"]) const viewer = viewerResult.data @@ -120,7 +123,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
diff --git a/src/components/dashboard/SiteSwitcher.tsx b/src/components/dashboard/SiteSwitcher.tsx index 240bfaf8..2ac7b0eb 100644 --- a/src/components/dashboard/SiteSwitcher.tsx +++ b/src/components/dashboard/SiteSwitcher.tsx @@ -1,31 +1,53 @@ import { Popover } from "@headlessui/react" import Link from "next/link" -import { useMemo } from "react" +import { useEffect, useMemo } from "react" +import { trpc } from "~/lib/trpc" import { getUserContentsUrl } from "~/lib/user-contents" import { Avatar } from "../ui/Avatar" type Props = { subdomain: string - sites: { id: string; name: string; subdomain: string; icon?: string | null }[] + subscriptions: { + id: string + site: { id: string; name: string; subdomain: string; icon?: string | null } + }[] viewer?: { email: string } | null } -export const SiteSwitcher: React.FC = ({ subdomain, sites, viewer }) => { - const activeSite = useMemo( - () => sites.find((s) => s.subdomain === subdomain), - [subdomain, sites] +export const SiteSwitcher: React.FC = ({ + subdomain, + subscriptions, + viewer, +}) => { + const activeSubscription = useMemo( + () => subscriptions.find((s) => s.site.subdomain === subdomain), + [subdomain, subscriptions] ) + + const { mutate: updateMembership } = trpc.useMutation( + "membership.updateMembership" + ) + + useEffect(() => { + if (activeSubscription?.id) { + updateMembership({ + id: activeSubscription?.id, + lastSwitchedTo: new Date().toISOString(), + }) + } + }, [activeSubscription?.id, updateMembership]) + return (
- {activeSite?.name} + {activeSubscription?.site.name}
@@ -48,12 +70,17 @@ export const SiteSwitcher: React.FC = ({ subdomain, sites, viewer }) => {
)}
- {sites?.map((site) => { + {subscriptions?.map((subscription) => { return ( - + - {site.name} - {activeSite?.id === site.id && ( + + {subscription.site.name} + + {activeSubscription?.id === subscription.id && ( ({ user, }: { @@ -41,6 +45,11 @@ export const createGate = ({ ) } + const userHasMembership = (membershipId: string) => { + if (!user) return false + return user.memberships.some((m) => m.id === membershipId) + } + return { getUser( requireAuth?: TRequiredAuth @@ -115,6 +124,10 @@ export const createGate = ({ ]) } + if (action.type === "can-update-membership") { + return userHasMembership(action.membership.id) + } + return false }, permissionError(message = "not allowed") { diff --git a/src/models/membership.ts b/src/models/membership.ts new file mode 100644 index 00000000..190077b4 --- /dev/null +++ b/src/models/membership.ts @@ -0,0 +1,57 @@ +import { string } from "zod" +import { prismaRead, MembershipRole, prismaPrimary } from "~/lib/db.server" + +export const getMembership = async (data: { + siteId: string + userId: string + role: MembershipRole +}) => { + const first = await prismaRead.membership.findFirst({ + where: { + role: data.role, + userId: data.userId, + siteId: data.siteId, + }, + }) + + return first +} + +export const getMemberships = async ({ + userId, + // Only return memberships that the user is an owner/admin of + canManage, +}: { + userId: string + canManage?: boolean +}) => { + const memberships = await prismaRead.membership.findMany({ + where: { + userId, + role: canManage + ? { + in: [MembershipRole.ADMIN, MembershipRole.OWNER], + } + : undefined, + }, + include: { + site: true, + }, + }) + + return memberships +} + +export const updateMembership = async ( + membershipId: string, + input: { lastSwitchedTo?: Date } +) => { + await prismaPrimary.membership.update({ + where: { + id: membershipId, + }, + data: { + lastSwitchedTo: input.lastSwitchedTo, + }, + }) +} diff --git a/src/models/site.model.ts b/src/models/site.model.ts index 919e2106..b6239ccd 100644 --- a/src/models/site.model.ts +++ b/src/models/site.model.ts @@ -6,6 +6,7 @@ import dayjs from "dayjs" import { sendLoginEmail } from "~/lib/mailgun.server" import { SiteNavigationItem, SubscribeFormData } from "~/lib/types" import { nanoid } from "nanoid" +import { getMembership } from "./membership" export const checkSubdomain = async ({ subdomain, @@ -35,12 +36,6 @@ export const checkSubdomain = async ({ } } -export async function getSitesForViewer(gate: Gate) { - const user = gate.getUser(true) - const sites = await getSitesByUser({ userId: user.id }) - return sites -} - export const getUserLastActiveSite = async (userId: string) => { const memberships = await prismaPrimary.membership.findMany({ where: { @@ -86,37 +81,6 @@ export const getSite = async (input: string) => { } } -export const getMembership = async (data: { - siteId: string - userId: string - role: MembershipRole -}) => { - const first = await prismaRead.membership.findFirst({ - where: { - role: data.role, - userId: data.userId, - siteId: data.siteId, - }, - }) - - return first -} - -export const getSitesByUser = async ({ userId }: { userId: string }) => { - const memberships = await prismaRead.membership.findMany({ - where: { - userId, - role: { - in: [MembershipRole.ADMIN, MembershipRole.OWNER], - }, - }, - include: { - site: true, - }, - }) - return memberships.map((m) => m.site) -} - export const getSubscription = async (data: { userId: string siteId: string diff --git a/src/router/dashboard.ts b/src/router/dashboard.ts deleted file mode 100644 index defac535..00000000 --- a/src/router/dashboard.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from "zod" -import { createRouter } from "~/lib/trpc.server" -import { getSitesForViewer } from "~/models/site.model" - -export const dashboardRouter = createRouter().query("sitesForSwitcher", { - output: z.array( - z.object({ - id: z.string(), - name: z.string(), - subdomain: z.string(), - createdAt: z.date().transform((v) => v.toISOString()), - icon: z.string().nullable(), - }) - ), - async resolve({ ctx }) { - const sites = await getSitesForViewer(ctx.gate) - return sites - }, -}) diff --git a/src/router/index.ts b/src/router/index.ts index e874079d..96630d5f 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -4,7 +4,7 @@ import { isNotFoundError } from "~/lib/server-side-props" import { TRPCContext } from "~/lib/trpc.server" import { getSite } from "~/models/site.model" import { authRouter } from "./auth" -import { dashboardRouter } from "./dashboard" +import { membershipRouter } from "./membership" import { siteRouter } from "./site" import { userRouter } from "./user" @@ -36,9 +36,9 @@ export const appRouter = trpc }, }) .merge("auth.", authRouter) - .merge("dashbaord.", dashboardRouter) .merge("site.", siteRouter) .merge("user.", userRouter) + .merge("membership.", membershipRouter) .formatError(({ error, shape }) => { return { ...shape, diff --git a/src/router/membership.ts b/src/router/membership.ts new file mode 100644 index 00000000..f4ae0905 --- /dev/null +++ b/src/router/membership.ts @@ -0,0 +1,23 @@ +import { z } from "zod" +import { createRouter } from "~/lib/trpc.server" +import { updateMembership } from "~/models/membership" + +export const membershipRouter = createRouter().mutation("updateMembership", { + input: z.object({ + id: z.string(), + lastSwitchedTo: z + .string() + .transform((v) => new Date()) + .optional(), + }), + async resolve({ input, ctx }) { + const { id, ...payload } = input + if ( + !ctx.gate.allows({ type: "can-update-membership", membership: { id } }) + ) { + throw ctx.gate.permissionError() + } + + await updateMembership(id, payload) + }, +}) diff --git a/src/router/user.ts b/src/router/user.ts index 1c14b5e3..bad23aa7 100644 --- a/src/router/user.ts +++ b/src/router/user.ts @@ -1,8 +1,10 @@ +import { MembershipRole } from "@prisma/client" import { encrypt, getDerivedKey } from "@proselog/jwt" import { z } from "zod" import { IS_PROD } from "~/lib/constants" import { ENCRYPT_SECRET } from "~/lib/env.server" import { createRouter } from "~/lib/trpc.server" +import { getMemberships } from "~/models/membership" import { userModel } from "~/models/user.model" export const userRouter = createRouter() @@ -21,6 +23,30 @@ export const userRouter = createRouter() return token }, }) + .query("getSubscriptions", { + input: z.object({ + canManage: z.boolean().optional(), + }), + output: z.array( + z.object({ + id: z.string(), + role: z.enum([ + MembershipRole.ADMIN, + MembershipRole.OWNER, + MembershipRole.SUBSCRIBER, + ]), + site: z.object({ + id: z.string(), + name: z.string(), + subdomain: z.string(), + }), + }) + ), + async resolve({ ctx, input }) { + const user = ctx.gate.getUser(true) + return getMemberships({ userId: user.id, ...input }) + }, + }) .mutation("updateProfile", { input: z.object({ username: z.string().optional(),