feat: manually send email for new posts

This commit is contained in:
EGOIST 2022-06-07 20:54:50 +08:00
parent f2a6492ceb
commit 26a93cf45b
17 changed files with 424 additions and 221 deletions

View File

@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `subscribersNotifiedAt` on the `pages` table. All the data in the column will be lost.
*/
-- CreateEnum
CREATE TYPE "PageEmailStatus" AS ENUM ('PENDING', 'RUNNING', 'SUCCESS', 'FAILED');
-- AlterTable
ALTER TABLE "pages" DROP COLUMN "subscribersNotifiedAt",
ADD COLUMN "emailStatus" "PageEmailStatus",
ADD COLUMN "emailSubject" TEXT;

View File

@ -101,26 +101,34 @@ enum PageType {
PAGE
}
enum PageEmailStatus {
PENDING
RUNNING
SUCCESS
FAILED
}
model Page {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contentUpdatedAt DateTime @default(now())
publishedAt DateTime @default(now())
published Boolean @default(false)
type PageType @default(POST)
title String
content String
excerpt String?
format String @default("markdown")
slug String
site Site @relation(name: "SitePages", fields: [siteId], references: [id], onDelete: Cascade)
siteId String
deletedAt DateTime?
subscribersNotifiedAt DateTime?
authors User[]
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contentUpdatedAt DateTime @default(now())
publishedAt DateTime @default(now())
published Boolean @default(false)
type PageType @default(POST)
title String
content String
excerpt String?
format String @default("markdown")
slug String
site Site @relation(name: "SitePages", fields: [siteId], references: [id], onDelete: Cascade)
siteId String
deletedAt DateTime?
authors User[]
// prerendered markdown to html, including extra data like excerpt
rendered Json?
rendered Json?
emailSubject String?
emailStatus PageEmailStatus?
@@unique([siteId, slug])
@@map("pages")

View File

@ -0,0 +1,57 @@
import { PageEmailStatus } from "@prisma/client"
import { useForm } from "react-hook-form"
import toast from "react-hot-toast"
import { useStore } from "~/lib/store"
import { trpc } from "~/lib/trpc"
import { Button } from "../ui/Button"
import { Input } from "../ui/Input"
import { Modal } from "../ui/Modal"
export const EmailPostModal: React.FC<{
pageId: string
}> = ({ pageId }) => {
const [open, setOpen] = useStore((store) => [
store.emailPostModalOpened,
store.setEmailPostModalOpened,
])
const { handleSubmit, register } = useForm({
defaultValues: {
subject: "",
},
})
const scheduleEmailForPost = trpc.useMutation("site.scheduleEmailForPost")
const onSubmit = handleSubmit(async (values) => {
await scheduleEmailForPost.mutateAsync({
pageId,
emailSubject: values.subject,
})
toast.success("Scheduled!")
setOpen(false)
})
return (
<Modal title="Email Post" open={open} setOpen={setOpen}>
{
<form onSubmit={onSubmit}>
<div className="p-5">
<Input
label="Email subject"
id="subject"
help="Defaults to post title"
isBlock
{...register("subject")}
/>
</div>
<div className="p-5 border-t">
<Button type="submit" isLoading={scheduleEmailForPost.isLoading}>
<span className="i-mdi:email-send text-xl mr-1"></span>
<span>Send Email</span>
</Button>
</div>
</form>
}
</Modal>
)
}

View File

@ -1,6 +1,5 @@
import { Button } from "~/components/ui/Button"
import { useStore } from "~/lib/store"
import { Dialog } from "@headlessui/react"
import { trpc } from "~/lib/trpc"
import { useForm } from "react-hook-form"
import { Modal } from "../ui/Modal"

View File

@ -126,8 +126,10 @@ export const useEditor = ({
return {
view,
editorRef(node: HTMLDivElement) {
setNode(node)
editorRef(newNode: HTMLDivElement) {
if (!newNode) return
if (node && newNode.isEqualNode(node)) return
setNode(newNode)
},
}
}

View File

@ -32,7 +32,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({
const active = isPreviewModeCommand && previewVisible
const disabled = !isPreviewModeCommand && previewVisible
return (
<Tooltip label={label} placement="bottom">
<Tooltip key={name} label={label} placement="bottom">
<button
key={name}
type="button"

View File

@ -0,0 +1,10 @@
export const FieldLabel: React.FC<{ id?: string; label: string }> = ({
id,
label,
}) => {
return (
<label className="form-label" htmlFor={id}>
{label}
</label>
)
}

View File

@ -1,5 +1,6 @@
import clsx from "clsx"
import { forwardRef } from "react"
import { FieldLabel } from "./FieldLabel"
type InputProps<TMultiline extends boolean> = {
label?: string
@ -11,7 +12,7 @@ type InputProps<TMultiline extends boolean> = {
} & React.ComponentPropsWithRef<TMultiline extends true ? "textarea" : "input">
export const Input = forwardRef(function Input<
TMutliline extends boolean = false
TMutliline extends boolean = false,
>(
{
label,
@ -25,18 +26,14 @@ export const Input = forwardRef(function Input<
}: InputProps<TMutliline>,
ref: TMutliline extends true
? React.ForwardedRef<HTMLTextAreaElement>
: React.ForwardedRef<HTMLInputElement>
: React.ForwardedRef<HTMLInputElement>,
) {
const hasAddon = !!addon
const Component = (multiline ? "textarea" : "input") as any
return (
<div>
{label && (
<label className="form-label" htmlFor={inputProps.id}>
{label}
</label>
)}
{label && <FieldLabel label={label} id={inputProps.id} />}
<div className="flex items-center">
<Component
{...inputProps}
@ -45,7 +42,7 @@ export const Input = forwardRef(function Input<
"input",
hasAddon && `has-addon`,
isBlock && `is-block`,
className
className,
)}
/>
{addon && (
@ -61,5 +58,5 @@ export const Input = forwardRef(function Input<
</div>
)
}) as <TMultiline extends boolean = false>(
props: InputProps<TMultiline>
props: InputProps<TMultiline>,
) => JSX.Element

View File

@ -17,6 +17,7 @@ type Action =
type: "can-create-page"
siteId: string
}
| { type: "can-update-page"; siteId: string }
| {
type: "can-read-page"
page: Page
@ -41,7 +42,7 @@ export const createGate = <TRequiredAuth extends boolean | undefined>({
const isSiteMember = (siteId: string, roles: MembershipRole[]) => {
if (!user) return false
return user.memberships.some(
(m) => m.siteId === siteId && roles.includes(m.role)
(m) => m.siteId === siteId && roles.includes(m.role),
)
}
@ -52,7 +53,7 @@ export const createGate = <TRequiredAuth extends boolean | undefined>({
return {
getUser<TRequiredAuth extends boolean | undefined>(
requireAuth?: TRequiredAuth
requireAuth?: TRequiredAuth,
) {
if (requireAuth && !user) {
throw new Error("unauthorized")
@ -95,6 +96,13 @@ export const createGate = <TRequiredAuth extends boolean | undefined>({
])
}
if (action.type === "can-update-page") {
return isSiteMember(action.siteId, [
MembershipRole.ADMIN,
MembershipRole.OWNER,
])
}
if (action.type === "can-read-page") {
const isPublished =
action.page.published &&

View File

@ -114,7 +114,7 @@ export const sendLoginEmail = async (payload: {
<a href="${loginLink}">confirm</a>
<p>This link will expire in 20 minutes.</p>`
<p>This link will expire in 7 days. If you did not request this link, you can safely ignore this email.</p>`
}
const message: MailgunMessageData = {
@ -128,13 +128,21 @@ export const sendLoginEmail = async (payload: {
}
export const sendEmailForNewPost = async (payload: {
post: { slug: string; title: string; rendered: { contentHTML: string } }
post: {
emailSubject?: string | null
slug: string
title: string
rendered: { contentHTML: string }
}
site: Site
subscribers: { id: string; email: string }[]
}) => {
if (payload.subscribers.length === 0) return
try {
const from = `${payload.site.name} <updates@${payload.site.subdomain}.proselog.com>`
const subject = `${payload.post.title} - ${payload.site.name}`
const subject =
payload.post.emailSubject ||
`${payload.post.title} - ${payload.site.name}`
const siteLink = getSiteLink({ subdomain: payload.site.subdomain })
const html = `

View File

@ -7,6 +7,9 @@ export type Store = {
subscribeModalOpened: boolean
setSubscribeModalOpened: (open: boolean) => void
emailPostModalOpened: boolean
setEmailPostModalOpened: (open: boolean) => void
}
const { Provider, useStore } = createContext<StoreApi<Store>>()
@ -26,4 +29,9 @@ export const createStore = () =>
setSubscribeModalOpened(open) {
set({ subscribeModalOpened: open })
},
emailPostModalOpened: false,
setEmailPostModalOpened(open) {
set({ emailPostModalOpened: open })
},
}))

View File

@ -6,10 +6,13 @@ const key = singletonAsync("encryption_key", () =>
getDerivedKey(ENCRYPT_SECRET),
)
export const generateEncryptedToken = async (payload: any) => {
export const generateEncryptedToken = async (
payload: any,
expiresIn = "20m",
) => {
await key.wait
const token = await encrypt(payload, key.value, {
expiresIn: "20m",
expiresIn,
})
return token
}
@ -30,7 +33,10 @@ type LoginTokenPayload =
}
export const generateLoginToken = async (payload: LoginTokenPayload) => {
const token = await generateEncryptedToken(payload)
const token = await generateEncryptedToken(
payload,
payload.type === "subscribe" ? "7d" : "20m",
)
return token
}

View File

@ -3,17 +3,17 @@ import {
prismaPrimary,
Prisma,
prismaRead,
PageType,
MembershipRole,
PageEmailStatus,
} 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"
import { sendEmailForNewPost } from "~/lib/mailgun.server"
const checkPageSlug = async ({
slug,
@ -62,6 +62,7 @@ export async function createOrUpdatePage(
published?: boolean
publishedAt?: string
excerpt?: string
/** Only needed when creating page */
isPost?: boolean
},
) {
@ -101,8 +102,14 @@ export async function createOrUpdatePage(
throw new Error(`Page not found`)
}
if (!gate.allows({ type: "can-create-page", siteId: page.siteId })) {
throw gate.permissionError()
if (input.pageId) {
if (!gate.allows({ type: "can-update-page", siteId: page.siteId })) {
throw gate.permissionError()
}
} else {
if (!gate.allows({ type: "can-create-page", siteId: page.siteId })) {
throw gate.permissionError()
}
}
const slug = input.slug || page.slug
@ -129,18 +136,36 @@ export async function createOrUpdatePage(
},
})
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 scheduleEmailForPost(
gate: Gate,
input: { pageId: string; emailSubject?: string },
) {
const page = await getPage(gate, { page: input.pageId })
if (!gate.allows({ type: "can-update-page", siteId: page.siteId })) {
throw gate.permissionError()
}
return { page: updated }
if (page.emailStatus) {
throw new Error("Email already scheduled or sent")
}
await prismaPrimary.page.update({
where: {
id: page.id,
},
data: {
emailStatus: "PENDING",
emailSubject: input.emailSubject,
},
})
await notifySubscribersForNewPost(gate, {
pageId: page.id,
})
}
export async function getPagesBySite(
@ -332,13 +357,30 @@ export const notifySubscribersForNewPost = async (
const page = await getPage(gate, { page: input.pageId, render: true })
const site = await getSite(page.siteId)
if (page.emailStatus !== PageEmailStatus.PENDING) {
return
}
if (!page.published || page.publishedAt > new Date()) {
return
}
if (page.type === "PAGE") {
throw new Error("You can only notify subscribers for post updates")
}
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")
}
await prismaPrimary.page.update({
where: {
id: page.id,
},
data: {
emailStatus: PageEmailStatus.RUNNING,
},
})
const memberships = await prismaPrimary.membership.findMany({
where: {
@ -350,28 +392,33 @@ export const notifySubscribersForNewPost = async (
},
})
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)
.filter((member) => (member.config as any).email)
.map((member) => member.user)
// TODO: maybe run this in a job queue
// We will need to use BullMQ to send email for scheduled posts anyway
sendEmailForNewPost({
post: page,
site,
subscribers: emailSubscribers,
}).catch((error) => {
try {
await sendEmailForNewPost({
post: page,
site,
subscribers: emailSubscribers,
})
await prismaPrimary.page.update({
where: {
id: page.id,
},
data: {
emailStatus: PageEmailStatus.SUCCESS,
},
})
} catch (error) {
await prismaPrimary.page.update({
where: {
id: page.id,
},
data: {
emailStatus: PageEmailStatus.FAILED,
},
})
console.error("failed to send email", error)
})
}
}

View File

@ -2,13 +2,11 @@ import { prismaPrimary, prismaRead } from "~/lib/db.server"
import { isUUID } from "~/lib/uuid"
import { MembershipRole, PageType, type Site } from "~/lib/db.server"
import { Gate } from "~/lib/gate.server"
import dayjs from "dayjs"
import { sendLoginEmail } from "~/lib/mailgun.server"
import { SiteNavigationItem, SubscribeFormData } from "~/lib/types"
import { SiteNavigationItem } from "~/lib/types"
import { nanoid } from "nanoid"
import { getMembership } from "./membership"
import { checkReservedWords } from "~/lib/reserved-words"
import { generateLoginToken } from "~/lib/token.server"
export const checkSubdomain = async ({
subdomain,

View File

@ -2,7 +2,7 @@ import { type EditorView } from "@codemirror/view"
import clsx from "clsx"
import dayjs from "dayjs"
import { useRouter } from "next/router"
import { ChangeEvent, useCallback, useEffect, useState } from "react"
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react"
import toast from "react-hot-toast"
import { modeToolbars, toolbars } from "~/editor"
import { DashboardLayout } from "~/components/dashboard/DashboardLayout"
@ -19,6 +19,10 @@ import { getSiteLink } from "~/lib/helpers"
import { getPageVisibility } from "~/lib/page-helpers"
import { trpc } from "~/lib/trpc"
import { PageVisibilityEnum } from "~/lib/types"
import { FieldLabel } from "~/components/ui/FieldLabel"
import { Button } from "~/components/ui/Button"
import { EmailPostModal } from "~/components/common/EmailPostModal"
import { useStore } from "~/lib/store"
const getInputDatetimeValue = (date: Date | string) => {
const str = dayjs(date).format()
@ -35,13 +39,14 @@ export default function SubdomainEditor() {
const siteResult = trpc.useQuery(["site", { site: subdomain }], {
enabled: !!subdomain,
})
const { data: page } = trpc.useQuery(
const { data: page, refetch: refetchPage } = trpc.useQuery(
["site.page", { page: pageId!, site: subdomain, render: false }],
{
enabled: !!pageId,
refetchOnWindowFocus: false,
},
)
const pageContent = useMemo(() => page?.content, [page?.content])
const published = page?.published ?? false
const visibility = getPageVisibility({
published,
@ -55,6 +60,10 @@ export default function SubdomainEditor() {
reset: resetCreateOrUpdatePage,
} = trpc.useMutation("page.createOrUpdate")
const uploadFile = useUploadFile()
const [emailPostModalOpened, setEmailPostModalOpened] = useStore((store) => [
store.emailPostModalOpened,
store.setEmailPostModalOpened,
])
const [values, setValues] = useState({
title: "",
@ -170,132 +179,177 @@ export default function SubdomainEditor() {
slug: page.slug,
excerpt: page.excerpt || "",
})
setContent(page.content)
}, [page])
useEffect(() => {
if (typeof pageContent === "string") {
setContent(pageContent)
}
}, [pageContent])
useEffect(() => {
if (!emailPostModalOpened) {
refetchPage()
}
}, [refetchPage, emailPostModalOpened])
return (
<DashboardLayout title="Editor">
<DashboardMain fullWidth>
<header className="flex justify-between absolute top-0 left-0 right-0 z-10 px-5 h-14 border-b items-center text-sm">
<EditorToolbar
view={view}
toolbars={toolbars}
modeToolbars={modeToolbars}
previewVisible={previewVisible}
setPreviewVisible={setPreviewVisible}
></EditorToolbar>
<div className="flex items-center space-x-3">
<span
className={clsx(
`text-sm`,
published ? `text-accent` : `text-zinc-300`,
)}
>
{visibility === PageVisibilityEnum.Published
? "Published"
: visibility === PageVisibilityEnum.Scheduled
? "Scheduled"
: "Draft"}
</span>
<PublishButton
save={savePage}
published={published}
isSaving={createOrUpdatePageStatus === "loading"}
/>
</div>
</header>
<div className="h-screen pt-14 flex w-full">
<div className="h-full overflow-auto w-full">
<div className="max-w-screen-md mx-auto py-5 px-5">
<>
<DashboardLayout title="Editor">
<DashboardMain fullWidth>
<header className="flex justify-between absolute top-0 left-0 right-0 z-10 px-5 h-14 border-b items-center text-sm">
<EditorToolbar
view={view}
toolbars={toolbars}
modeToolbars={modeToolbars}
previewVisible={previewVisible}
setPreviewVisible={setPreviewVisible}
></EditorToolbar>
<div className="flex items-center space-x-3">
<span
className={clsx(
`text-sm`,
published ? `text-accent` : `text-zinc-300`,
)}
>
{visibility === PageVisibilityEnum.Published
? "Published"
: visibility === PageVisibilityEnum.Scheduled
? "Scheduled"
: "Draft"}
</span>
<PublishButton
save={savePage}
published={published}
isSaving={createOrUpdatePageStatus === "loading"}
/>
</div>
</header>
<div className="h-screen pt-14 flex w-full">
<div className="h-full overflow-auto w-full">
<div className="max-w-screen-md mx-auto py-5 px-5">
<div>
<input
type="text"
name="title"
value={values.title}
onKeyDown={(e) => {
if (e.key === "Enter") {
view?.focus()
}
}}
onChange={(e) => updateValue("title", e.target.value)}
className="h-12 ml-1 inline-flex items-center border-none text-3xl font-bold w-full focus:outline-none"
placeholder="Title goes here.."
/>
</div>
<div className="mt-5">
{previewVisible ? (
<EditorPreview content={content}></EditorPreview>
) : (
<div ref={editorRef}></div>
)}
</div>
</div>
</div>
<div className="h-full overflow-auto flex-shrink-0 w-[280px] border-l bg-zinc-50 p-5 space-y-5">
<div>
<input
type="text"
name="title"
value={values.title}
onKeyDown={(e) => {
if (e.key === "Enter") {
view?.focus()
}
<Input
type="datetime-local"
label="Publish at"
isBlock
name="publishAt"
id="publishAt"
value={getInputDatetimeValue(values.publishedAt)}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const value = inLocalTimezone(e.target.value).toISOString()
updateValue("publishedAt", value)
}}
onChange={(e) => updateValue("title", e.target.value)}
className="h-12 ml-1 inline-flex items-center border-none text-3xl font-bold w-full focus:outline-none"
placeholder="Title goes here.."
help={`This ${
isPost ? "post" : "page"
} will be accesisble from this time`}
/>
</div>
<div className="mt-5">
{previewVisible ? (
<EditorPreview content={content}></EditorPreview>
) : (
<div ref={editorRef}></div>
)}
<div>
<Input
name="slug"
value={values.slug}
label="Page slug"
id="slug"
isBlock
placeholder="some-slug"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
updateValue("slug", e.target.value)
}
help={
<>
{values.slug && (
<>
This {isPost ? "post" : "page"} will be accessible at{" "}
<UniLink
href={`${getSiteLink({ subdomain })}/${
values.slug
}`}
className="hover:underline"
>
{getSiteLink({ subdomain, noProtocol: true })}/
{values.slug}
</UniLink>
</>
)}
</>
}
/>
</div>
<div>
<Input
label="Excerpt"
isBlock
name="excerpt"
id="excerpt"
value={values.excerpt}
multiline
rows={5}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
updateValue("excerpt", e.target.value)
}}
help="Leave it blank to use auto-generated excerpt"
/>
</div>
{isPost && page && (
<div>
<FieldLabel label="Email Post" id="email-post" />
{page?.emailStatus ? (
<div className="capitalize text-zinc-500 text-sm flex items-center space-x-1">
<span
className={clsx(
page.emailStatus === "SUCCESS"
? `i-bi:check-circle text-green-500`
: page.emailStatus === "FAILED"
? `i-bi:x-circle text-red-500`
: ``,
)}
></span>
<span>{page.emailStatus.toLowerCase()}</span>
</div>
) : (
<Button
variant="primary"
variantColor="gray"
size="sm"
isBlock
onClick={() => setEmailPostModalOpened(true)}
>
Open Settings
</Button>
)}
</div>
)}
</div>
</div>
<div className="h-full overflow-auto flex-shrink-0 w-[280px] border-l bg-zinc-50 p-5 space-y-5">
<div>
<Input
type="datetime-local"
label="Publish at"
isBlock
name="publishAt"
id="publishAt"
value={getInputDatetimeValue(values.publishedAt)}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const value = inLocalTimezone(e.target.value).toISOString()
updateValue("publishedAt", value)
}}
help={`This ${
isPost ? "post" : "page"
} will be accesisble from this time`}
/>
</div>
<div>
<Input
name="slug"
value={values.slug}
label="Page slug"
id="slug"
isBlock
placeholder="some-slug"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
updateValue("slug", e.target.value)
}
help={
<>
{values.slug && (
<>
This {isPost ? "post" : "page"} will be accessible at{" "}
<UniLink
href={`${getSiteLink({ subdomain })}/${values.slug}`}
className="hover:underline"
>
{getSiteLink({ subdomain, noProtocol: true })}/
{values.slug}
</UniLink>
</>
)}
</>
}
/>
</div>
<div>
<Input
label="Excerpt"
isBlock
name="excerpt"
id="excerpt"
value={values.excerpt}
multiline
rows={5}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
updateValue("excerpt", e.target.value)
}}
help="Leave it blank to use auto-generated excerpt"
/>
</div>
</div>
</div>
</DashboardMain>
</DashboardLayout>
</DashboardMain>
</DashboardLayout>
{pageId && <EmailPostModal pageId={pageId} />}
</>
)
}

View File

@ -15,8 +15,9 @@ export const pageRouter = createRouter()
published: z.boolean().optional(),
publishedAt: z.string().optional(),
excerpt: z.string().optional(),
isPost: z.boolean(),
isPost: z.boolean().optional(),
slug: z.string().optional(),
emailSubject: z.string().optional(),
}),
output: z.object({
id: z.string(),

View File

@ -10,30 +10,12 @@ import {
unsubscribeFromSite,
} from "~/models/site.model"
import {
createOrUpdatePage,
getPage,
deletePage,
getPagesBySite,
notifySubscribersForNewPost,
scheduleEmailForPost,
} from "~/models/page.model"
export const siteRouter = createRouter()
.query("subscribersNotifiedAt", {
input: z.object({
pageId: z.string(),
}),
output: z
.date()
.transform((v) => v.toISOString())
.nullable(),
async resolve({ input, ctx }) {
const page = await getPage(ctx.gate, { page: input.pageId })
if (!ctx.gate.isOwnerOrAdmin(page.siteId)) {
throw ctx.gate.permissionError()
}
return page.subscribersNotifiedAt
},
})
.query("subscription", {
input: z.object({
site: z.string(),
@ -123,6 +105,9 @@ export const siteRouter = createRouter()
}),
)
.optional(),
emailSubject: z.string().nullish(),
emailStatus: z.string().nullish(),
siteId: z.string(),
}),
async resolve({ input, ctx }) {
const page = await getPage(ctx.gate, input)
@ -205,13 +190,15 @@ export const siteRouter = createRouter()
await unsubscribeFromSite(ctx.gate, input)
},
})
.mutation("notifySubscribersForNewPost", {
.mutation("scheduleEmailForPost", {
input: z.object({
pageId: z.string(),
emailSubject: z.string().optional(),
}),
async resolve({ ctx, input }) {
await notifySubscribersForNewPost(ctx.gate, {
await scheduleEmailForPost(ctx.gate, {
pageId: input.pageId,
emailSubject: input.emailSubject,
})
},
})