feat: update lastSwitchedTo after switching site

This commit is contained in:
EGOIST 2022-05-16 21:10:38 +08:00
parent c3aa3fd967
commit 0b2c0f0d80
10 changed files with 169 additions and 74 deletions

View File

@ -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("{}")

View File

@ -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>

View File

@ -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"

View File

@ -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") {

57
src/models/membership.ts Normal file
View File

@ -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,
},
})
}

View File

@ -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

View File

@ -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
},
})

View File

@ -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,

23
src/router/membership.ts Normal file
View File

@ -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)
},
})

View File

@ -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(),