feat: update lastSwitchedTo after switching site
This commit is contained in:
parent
c3aa3fd967
commit
0b2c0f0d80
|
|
@ -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("{}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<div className="mb-2">
|
||||
<SiteSwitcher
|
||||
subdomain={subdomain}
|
||||
sites={sites || []}
|
||||
subscriptions={subscriptions || []}
|
||||
viewer={viewer}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<Props> = ({ subdomain, sites, viewer }) => {
|
||||
const activeSite = useMemo(
|
||||
() => sites.find((s) => s.subdomain === subdomain),
|
||||
[subdomain, sites]
|
||||
export const SiteSwitcher: React.FC<Props> = ({
|
||||
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 (
|
||||
<div className="px-3 pt-3 pb-2 text-sm">
|
||||
<Popover className="relative">
|
||||
<Popover.Button className="h-8 px-2 justify-between flex w-full rounded-lg hover:bg-gray-200 hover:bg-opacity-50 transition-colors items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar
|
||||
images={[getUserContentsUrl(activeSite?.icon)]}
|
||||
name={activeSite?.name}
|
||||
images={[getUserContentsUrl(activeSubscription?.site.icon)]}
|
||||
name={activeSubscription?.site.name}
|
||||
size={22}
|
||||
/>
|
||||
<span className="truncate">{activeSite?.name}</span>
|
||||
<span className="truncate">{activeSubscription?.site.name}</span>
|
||||
</div>
|
||||
<span className="w-5 h-5 text-zinc-400 rounded-full bg-zinc-100 inline-flex items-center justify-center">
|
||||
<svg className="w-4 h-4" viewBox="0 0 14 14">
|
||||
|
|
@ -48,12 +70,17 @@ export const SiteSwitcher: React.FC<Props> = ({ subdomain, sites, viewer }) => {
|
|||
</div>
|
||||
)}
|
||||
<div className="p-2">
|
||||
{sites?.map((site) => {
|
||||
{subscriptions?.map((subscription) => {
|
||||
return (
|
||||
<Link key={site.id} href={`/dashboard/${site.subdomain}`}>
|
||||
<Link
|
||||
key={subscription.id}
|
||||
href={`/dashboard/${subscription.site.subdomain}`}
|
||||
>
|
||||
<a className="flex px-2 h-8 rounded-lg items-center justify-between hover:bg-zinc-100">
|
||||
<span className="truncate w-8/12">{site.name}</span>
|
||||
{activeSite?.id === site.id && (
|
||||
<span className="truncate w-8/12">
|
||||
{subscription.site.name}
|
||||
</span>
|
||||
{activeSubscription?.id === subscription.id && (
|
||||
<span className="text-accent">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ type Action =
|
|||
type: "can-notify-site-subscribers"
|
||||
site: Site
|
||||
}
|
||||
| {
|
||||
type: "can-update-membership"
|
||||
membership: { id: string }
|
||||
}
|
||||
export const createGate = <TRequiredAuth extends boolean | undefined>({
|
||||
user,
|
||||
}: {
|
||||
|
|
@ -41,6 +45,11 @@ export const createGate = <TRequiredAuth extends boolean | undefined>({
|
|||
)
|
||||
}
|
||||
|
||||
const userHasMembership = (membershipId: string) => {
|
||||
if (!user) return false
|
||||
return user.memberships.some((m) => m.id === membershipId)
|
||||
}
|
||||
|
||||
return {
|
||||
getUser<TRequiredAuth extends boolean | undefined>(
|
||||
requireAuth?: TRequiredAuth
|
||||
|
|
@ -115,6 +124,10 @@ export const createGate = <TRequiredAuth extends boolean | undefined>({
|
|||
])
|
||||
}
|
||||
|
||||
if (action.type === "can-update-membership") {
|
||||
return userHasMembership(action.membership.id)
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
permissionError(message = "not allowed") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue