354 lines
7.5 KiB
TypeScript
354 lines
7.5 KiB
TypeScript
import { nanoid } from "nanoid"
|
|
import {
|
|
prismaPrimary,
|
|
Prisma,
|
|
prismaRead,
|
|
PageType,
|
|
MembershipRole,
|
|
} from "~/lib/db.server"
|
|
import { type Gate } from "~/lib/gate.server"
|
|
import { sendEmailForNewPost } from "~/lib/mailgun.server"
|
|
import { Rendered, renderPageContent } from "~/markdown"
|
|
import { notFound } from "~/lib/server-side-props"
|
|
import { PageVisibilityEnum } from "~/lib/types"
|
|
import { isUUID } from "~/lib/uuid"
|
|
import { getSite } from "./site.model"
|
|
import { stripHTML } from "~/lib/utils"
|
|
|
|
const checkPageSlug = async ({
|
|
slug,
|
|
excludePage,
|
|
siteId,
|
|
}: {
|
|
slug: string
|
|
excludePage?: string
|
|
siteId: string
|
|
}) => {
|
|
if (!slug) {
|
|
throw new Error("Missing page slug")
|
|
}
|
|
|
|
const page = await prismaPrimary.page.findFirst({
|
|
where: {
|
|
siteId,
|
|
slug,
|
|
id: excludePage && {
|
|
not: excludePage,
|
|
},
|
|
},
|
|
})
|
|
if (!page) return
|
|
|
|
if (page.deletedAt) {
|
|
await prismaPrimary.page.delete({
|
|
where: {
|
|
id: page.id,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
throw new Error("Page slug already used")
|
|
}
|
|
|
|
export async function createOrUpdatePage(
|
|
gate: Gate,
|
|
input: {
|
|
pageId?: string
|
|
siteId: string
|
|
slug?: string
|
|
title?: string
|
|
content?: string
|
|
published?: boolean
|
|
publishedAt?: string
|
|
excerpt?: string
|
|
isPost?: boolean
|
|
}
|
|
) {
|
|
const user = gate.getUser(true)
|
|
const page = input.pageId
|
|
? await prismaPrimary.page.findUnique({
|
|
where: {
|
|
id: input.pageId,
|
|
},
|
|
include: {
|
|
site: true,
|
|
},
|
|
})
|
|
: await prismaPrimary.page.create({
|
|
data: {
|
|
title: "Untitled",
|
|
slug: `untitled-${nanoid(4)}`,
|
|
site: {
|
|
connect: {
|
|
id: input.siteId,
|
|
},
|
|
},
|
|
content: "",
|
|
excerpt: "",
|
|
authors: {
|
|
connect: {
|
|
id: user.id,
|
|
},
|
|
},
|
|
},
|
|
include: {
|
|
site: true,
|
|
},
|
|
})
|
|
|
|
if (!page || page.deletedAt) {
|
|
throw new Error(`Page not found`)
|
|
}
|
|
|
|
if (!gate.allows({ type: "can-create-page", siteId: page.siteId })) {
|
|
throw gate.permissionError()
|
|
}
|
|
|
|
const slug = input.slug || page.slug
|
|
await checkPageSlug({ slug, excludePage: page.id, siteId: page.siteId })
|
|
|
|
// Just checking if the page content can be rendered
|
|
await renderPageContent(page.content)
|
|
|
|
const updated = await prismaPrimary.page.update({
|
|
where: {
|
|
id: page.id,
|
|
},
|
|
data: {
|
|
title: input.title || "Untitled",
|
|
content: input.content,
|
|
published: input.published,
|
|
publishedAt: input.publishedAt && new Date(input.publishedAt),
|
|
excerpt: input.excerpt && stripHTML(input.excerpt),
|
|
slug,
|
|
type: input.isPost ? "POST" : "PAGE",
|
|
},
|
|
})
|
|
|
|
if (
|
|
updated.type === PageType.POST &&
|
|
!updated.subscribersNotifiedAt &&
|
|
updated.published &&
|
|
updated.publishedAt <= new Date()
|
|
) {
|
|
await notifySubscribersForNewPost(gate, {
|
|
pageId: updated.id,
|
|
})
|
|
}
|
|
|
|
return { page: updated }
|
|
}
|
|
|
|
export async function getPagesBySite(
|
|
gate: Gate,
|
|
input: {
|
|
site: string
|
|
type: "post" | "page"
|
|
visibility?: PageVisibilityEnum | null
|
|
take?: number | null
|
|
cursor?: string | null
|
|
}
|
|
) {
|
|
const site = await getSite(input.site)
|
|
|
|
const visibility = input.visibility || PageVisibilityEnum.Published
|
|
|
|
if (!gate.allows({ type: "can-list-page", visibility, siteId: site.id })) {
|
|
throw gate.permissionError()
|
|
}
|
|
|
|
const now = new Date()
|
|
|
|
const where: Prisma.PageWhereInput = {
|
|
siteId: site.id,
|
|
deletedAt: null,
|
|
type: input.type === "post" ? "POST" : "PAGE",
|
|
}
|
|
if (visibility === PageVisibilityEnum.Published) {
|
|
where.published = true
|
|
where.publishedAt = {
|
|
lte: now,
|
|
}
|
|
} else if (visibility === PageVisibilityEnum.Scheduled) {
|
|
where.published = true
|
|
where.publishedAt = {
|
|
gt: now,
|
|
}
|
|
} else if (visibility === PageVisibilityEnum.Draft) {
|
|
where.published = false
|
|
}
|
|
|
|
const take = input.take || 100
|
|
const [nodes, total] = await Promise.all([
|
|
prismaRead.page.findMany({
|
|
where,
|
|
orderBy: {
|
|
publishedAt: "desc",
|
|
},
|
|
take: take + 1,
|
|
cursor: input.cursor
|
|
? {
|
|
id: input.cursor,
|
|
}
|
|
: undefined,
|
|
}),
|
|
await prismaRead.page.count({
|
|
where,
|
|
}),
|
|
])
|
|
|
|
const hasMore = nodes.length > take
|
|
|
|
const nodesRendered = await Promise.all(
|
|
nodes.slice(0, take).map(async (node) => {
|
|
const rendered = await renderPageContent(node.content)
|
|
return {
|
|
...node,
|
|
autoExcerpt: rendered.excerpt,
|
|
}
|
|
})
|
|
)
|
|
|
|
return {
|
|
nodes: nodesRendered,
|
|
total,
|
|
hasMore,
|
|
}
|
|
}
|
|
|
|
export async function deletePage(gate: Gate, { id }: { id: string }) {
|
|
const page = await prismaPrimary.page.findUnique({
|
|
where: {
|
|
id,
|
|
},
|
|
})
|
|
|
|
if (!page) {
|
|
throw notFound("page not found")
|
|
}
|
|
|
|
if (!gate.allows({ type: "can-delete-page", siteId: page.siteId })) {
|
|
throw gate.permissionError()
|
|
}
|
|
|
|
await prismaPrimary.page.update({
|
|
where: {
|
|
id: page.id,
|
|
},
|
|
data: {
|
|
deletedAt: new Date(),
|
|
},
|
|
})
|
|
}
|
|
|
|
export async function getPage<TRender extends boolean = false>(
|
|
gate: Gate,
|
|
input: {
|
|
/** page slug or id, `site` is needed when `page` is a slug */
|
|
page: string
|
|
site?: string
|
|
render?: TRender
|
|
includeAuthors?: boolean
|
|
}
|
|
) {
|
|
const site = input.site ? await getSite(input.site) : null
|
|
|
|
if (input.site && !site) {
|
|
throw notFound(`site not found`)
|
|
}
|
|
|
|
const isPageUUID = isUUID(input.page)
|
|
if (!isPageUUID && !input.site) {
|
|
throw new Error("input.site is required because input.page is a slug")
|
|
}
|
|
|
|
const page = isPageUUID
|
|
? await prismaRead.page.findUnique({
|
|
where: {
|
|
id: input.page,
|
|
},
|
|
include: {
|
|
authors: input.includeAuthors,
|
|
},
|
|
})
|
|
: site
|
|
? await prismaRead.page.findFirst({
|
|
where: { siteId: site.id, slug: input.page },
|
|
include: {
|
|
authors: input.includeAuthors,
|
|
},
|
|
})
|
|
: null
|
|
|
|
if (!page || page.deletedAt) {
|
|
throw notFound(`page ${input.page} not found`)
|
|
}
|
|
|
|
if (!gate.allows({ type: "can-read-page", page })) {
|
|
if (!page.published || page.publishedAt > new Date()) {
|
|
throw notFound()
|
|
} else {
|
|
throw gate.permissionError()
|
|
}
|
|
}
|
|
|
|
const rendered = (
|
|
input.render ? await renderPageContent(page.content) : null
|
|
) as TRender extends true ? Rendered : null
|
|
|
|
return {
|
|
...page,
|
|
rendered,
|
|
}
|
|
}
|
|
|
|
export const notifySubscribersForNewPost = async (
|
|
gate: Gate,
|
|
input: {
|
|
pageId: string
|
|
}
|
|
) => {
|
|
const page = await getPage(gate, { page: input.pageId, render: true })
|
|
const site = await getSite(page.siteId)
|
|
|
|
if (!gate.allows({ type: "can-notify-site-subscribers", site })) {
|
|
throw gate.permissionError()
|
|
}
|
|
|
|
if (page.subscribersNotifiedAt) {
|
|
throw new Error("You have already notified subscribers for this post")
|
|
}
|
|
|
|
const memberships = await prismaPrimary.membership.findMany({
|
|
where: {
|
|
role: MembershipRole.SUBSCRIBER,
|
|
siteId: site.id,
|
|
},
|
|
include: {
|
|
user: true,
|
|
},
|
|
})
|
|
|
|
if (memberships.length === 0) return
|
|
|
|
await prismaPrimary.page.update({
|
|
where: {
|
|
id: page.id,
|
|
},
|
|
data: {
|
|
subscribersNotifiedAt: new Date(),
|
|
},
|
|
})
|
|
|
|
const emailSubscribers = memberships
|
|
.filter((member) => (member.config as Prisma.JsonObject).email)
|
|
.map((member) => member.user)
|
|
|
|
await sendEmailForNewPost({
|
|
post: page,
|
|
site,
|
|
subscribers: emailSubscribers,
|
|
})
|
|
}
|