diff --git a/app/(editor)/create/[[...paramsArr]]/_client.tsx b/app/(editor)/create/[[...paramsArr]]/_client.tsx index b0d7bf30..a2793a57 100644 --- a/app/(editor)/create/[[...paramsArr]]/_client.tsx +++ b/app/(editor)/create/[[...paramsArr]]/_client.tsx @@ -37,6 +37,7 @@ import { uploadFile } from "@/utils/s3helpers"; import { getUploadUrl } from "@/app/actions/getUploadUrl"; import EditorNav from "./navigation"; import { type Session } from "next-auth"; +import { FormDataSchema } from "@/schema/post"; const Create = ({ session }: { session: Session | null }) => { const params = useParams(); @@ -161,6 +162,7 @@ const Create = ({ session }: { session: Session | null }) => { Sentry.captureException(error); }, }); + const { mutate: create, data: createData, @@ -212,11 +214,17 @@ const Create = ({ session }: { session: Session | null }) => { const getFormData = () => { const data = getValues(); + + const sanitizedSeriesName = FormDataSchema.parse({ + seriesName: data.seriesName, + }); + const formData = { ...data, tags, canonicalUrl: data.canonicalUrl || undefined, excerpt: data.excerpt || removeMarkdown(data.body, {}).substring(0, 155), + ...sanitizedSeriesName }; return formData; }; @@ -229,6 +237,7 @@ const Create = ({ session }: { session: Session | null }) => { await create({ ...formData }); } else { await save({ ...formData, id: postId }); + setSavedTime( new Date().toLocaleString(undefined, { dateStyle: "medium", @@ -564,10 +573,24 @@ const Create = ({ session }: { session: Session | null }) => { {copied ? "Copied" : "Copy Link"} -

+

Share this link with others to preview your draft. Anyone with the link can view your draft.

+ + + +

+ This text is case-sensitive so make sure you type it exactly as you did in previous articles to ensure they are connected +

)} diff --git a/drizzle/0000_initial_schema.sql b/drizzle/0000_initial_schema.sql index b0338104..430f7b0b 100644 --- a/drizzle/0000_initial_schema.sql +++ b/drizzle/0000_initial_schema.sql @@ -183,6 +183,7 @@ CREATE TABLE IF NOT EXISTS "Membership" ( "createdAt" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL ); --> statement-breakpoint + CREATE UNIQUE INDEX IF NOT EXISTS "Post_id_key" ON "Post" ("id");--> statement-breakpoint CREATE UNIQUE INDEX IF NOT EXISTS "Post_slug_key" ON "Post" ("slug");--> statement-breakpoint CREATE UNIQUE INDEX IF NOT EXISTS "PostTag_tagId_postId_key" ON "PostTag" ("tagId","postId");--> statement-breakpoint diff --git a/drizzle/0011_add_series.sql b/drizzle/0011_add_series.sql new file mode 100644 index 00000000..bc22b577 --- /dev/null +++ b/drizzle/0011_add_series.sql @@ -0,0 +1,17 @@ +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "Series" ( + "id" SERIAL PRIMARY KEY, + "name" TEXT NOT NULL, + "userId" text NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL +); +-->statement-breakpoint + +ALTER TABLE "Post" ADD COLUMN "seriesId" INTEGER; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "Post" ADD CONSTRAINT "Post_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "public"."Series" ("id") ON DELETE SET NULL; +EXCEPTION + WHEN duplicate_object THEN null; +END $$ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 83625f05..70b638e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19653,4 +19653,4 @@ } } } -} +} \ No newline at end of file diff --git a/schema/post.ts b/schema/post.ts index 224e8940..9f253fff 100644 --- a/schema/post.ts +++ b/schema/post.ts @@ -25,6 +25,12 @@ export const SavePostSchema = z.object({ canonicalUrl: z.optional(z.string().trim().url()), tags: z.string().array().max(5).optional(), published: z.string().datetime().optional(), + seriesName: z.string() + .trim() + .min(1, "Series name cannot be empty") + .max(50, "Series name is too long") + .regex(/^[\w\s-]+$/, "Series name can only contain letters, numbers, spaces, and hyphens") + .optional() }); export const PublishPostSchema = z.object({ @@ -66,6 +72,10 @@ export const GetPostsSchema = z.object({ tag: z.string().nullish(), }); +export const FormDataSchema = z.object({ + seriesName: z.string().trim().optional() +}) + export type SavePostInput = z.TypeOf; export type ConfirmPostInput = z.TypeOf; diff --git a/server/api/router/index.ts b/server/api/router/index.ts index d7274a63..49b12df9 100644 --- a/server/api/router/index.ts +++ b/server/api/router/index.ts @@ -15,7 +15,7 @@ export const appRouter = createTRPCRouter({ notification: notificationRouter, admin: adminRouter, report: reportRouter, - tag: tagRouter, + tag: tagRouter }); // export type definition of API diff --git a/server/api/router/post.ts b/server/api/router/post.ts index 8a41482b..0f38fb2d 100644 --- a/server/api/router/post.ts +++ b/server/api/router/post.ts @@ -14,7 +14,7 @@ import { GetLimitSidePosts, } from "../../../schema/post"; import { removeMarkdown } from "../../../utils/removeMarkdown"; -import { bookmark, like, post, post_tag, tag, user } from "@/server/db/schema"; +import { bookmark, like, post, post_tag, tag, user, series } from "@/server/db/schema"; import { and, eq, @@ -52,10 +52,13 @@ export const postRouter = createTRPCRouter({ update: protectedProcedure .input(SavePostSchema) .mutation(async ({ input, ctx }) => { - const { id, body, title, excerpt, canonicalUrl, tags = [] } = input; + const { id, body, title, excerpt, canonicalUrl, tags = [], seriesName } = input; const currentPost = await ctx.db.query.post.findFirst({ where: (posts, { eq }) => eq(posts.id, id), + with: { + series: true + }, }); if (currentPost?.userId !== ctx.session.user.id) { @@ -64,6 +67,94 @@ export const postRouter = createTRPCRouter({ }); } + // series + const postId = currentPost.id; + + if (seriesName?.trim() === "") { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Series name cannot be empty' }); + } + + const createNewSeries = async (seriesTitle: string) => { + return await ctx.db.transaction(async (tx) => { + let seriesId: number; + const currSeries = await tx.query.series.findFirst({ + columns: { + id: true + }, + where: (series, { eq, and }) => and( + eq(series.name, seriesTitle), + eq(series.userId, ctx.session.user.id) + ), + }) + + if (!currSeries) { + const [newSeries] = await tx.insert(series).values({ + name: seriesTitle, + userId: ctx.session.user.id, + updatedAt: new Date() + }).returning(); + + seriesId = newSeries.id; + } + else { + seriesId = currSeries.id; + } + await tx + .update(post) + .set({ + seriesId: seriesId + }) + .where(eq(post.id, currentPost.id)); + }) + + } + + const unlinkSeries = async (seriesId: number) => { + return await ctx.db.transaction(async (tx) => { + const anotherPostInThisSeries = await tx.query.post.findFirst({ + where: (post, { eq, and, ne }) => + and( + ne(post.id, currentPost.id), + eq(post.seriesId, seriesId) + ) + }) + if (!anotherPostInThisSeries) { + await tx.delete(series).where( + and( + eq(series.id, seriesId), + eq(series.userId, ctx.session.user.id) + ) + ); + } + // update that series id in the current post + await tx + .update(post) + .set({ + seriesId: null + }) + .where(eq(post.id, currentPost.id)); + }) + } + + if (seriesName) { + if (currentPost?.seriesId) { + if (currentPost?.series?.name !== seriesName) { + await unlinkSeries(currentPost.seriesId); + await createNewSeries(seriesName); + } + } + else { + await createNewSeries(seriesName); + } + } + else { + if (currentPost.seriesId !== null) { + await unlinkSeries(currentPost.seriesId); + } + } + + + // if user doesnt link any tags to the article no point in doing the tag operations // This also makes autosave during writing faster if (tags.length > 0) { @@ -105,7 +196,7 @@ export const postRouter = createTRPCRouter({ return excerpt && excerpt.length > 0 ? excerpt : // @Todo why is body string | null ? - removeMarkdown(currentPost.body as string, {}).substring(0, 156); + removeMarkdown(currentPost.body as string, {}).substring(0, 156); } return excerpt; }; @@ -187,10 +278,27 @@ export const postRouter = createTRPCRouter({ }); } - const [deletedPost] = await ctx.db - .delete(post) - .where(eq(post.id, id)) - .returning(); + const deletedPost = await ctx.db.transaction(async (tx) => { + const [deletedPost] = await tx + .delete(post) + .where(eq(post.id, id)) + .returning(); + + if (deletedPost.seriesId) { + // check is there is any other post with the current seriesId + const anotherPostInThisSeries = await tx.query.post.findFirst({ + where: (post, { eq }) => + eq(post.seriesId, deletedPost.seriesId!) + }) + // if another post with the same seriesId is present, then do nothing + // else remove the series from the series table + if (!anotherPostInThisSeries) { + await tx.delete(series).where(eq(series.id, deletedPost.seriesId)); + } + } + + return deletedPost; + }); return deletedPost; }), @@ -203,33 +311,33 @@ export const postRouter = createTRPCRouter({ setLiked ? await ctx.db.transaction(async (tx) => { - res = await tx.insert(like).values({ postId, userId }).returning(); + res = await tx.insert(like).values({ postId, userId }).returning(); + await tx + .update(post) + .set({ + likes: increment(post.likes), + }) + .where(eq(post.id, postId)); + }) + : await ctx.db.transaction(async (tx) => { + res = await tx + .delete(like) + .where( + and( + eq(like.postId, postId), + eq(like.userId, ctx.session?.user?.id), + ), + ) + .returning(); + if (res.length !== 0) { await tx .update(post) .set({ - likes: increment(post.likes), + likes: decrement(post.likes), }) .where(eq(post.id, postId)); - }) - : await ctx.db.transaction(async (tx) => { - res = await tx - .delete(like) - .where( - and( - eq(like.postId, postId), - eq(like.userId, ctx.session?.user?.id), - ), - ) - .returning(); - if (res.length !== 0) { - await tx - .update(post) - .set({ - likes: decrement(post.likes), - }) - .where(eq(post.id, postId)); - } - }); + } + }); return res; }), @@ -241,16 +349,16 @@ export const postRouter = createTRPCRouter({ setBookmarked ? await ctx.db - .insert(bookmark) - .values({ postId, userId: ctx.session?.user?.id }) + .insert(bookmark) + .values({ postId, userId: ctx.session?.user?.id }) : await ctx.db - .delete(bookmark) - .where( - and( - eq(bookmark.postId, postId), - eq(bookmark.userId, ctx.session?.user?.id), - ), - ); + .delete(bookmark) + .where( + and( + eq(bookmark.postId, postId), + eq(bookmark.userId, ctx.session?.user?.id), + ), + ); return res; }), sidebarData: publicProcedure @@ -267,26 +375,26 @@ export const postRouter = createTRPCRouter({ // if user not logged in and they wont have any liked posts so default to a count of 0 ctx.session?.user?.id ? ctx.db - .selectDistinct() - .from(like) - .where( - and( - eq(like.postId, id), - eq(like.userId, ctx.session.user.id), - ), - ) + .selectDistinct() + .from(like) + .where( + and( + eq(like.postId, id), + eq(like.userId, ctx.session.user.id), + ), + ) : [false], // if user not logged in and they wont have any bookmarked posts so default to a count of 0 ctx.session?.user?.id ? ctx.db - .selectDistinct() - .from(bookmark) - .where( - and( - eq(bookmark.postId, id), - eq(bookmark.userId, ctx.session.user.id), - ), - ) + .selectDistinct() + .from(bookmark) + .where( + and( + eq(bookmark.postId, id), + eq(bookmark.userId, ctx.session.user.id), + ), + ) : [false], ]); return { @@ -428,6 +536,7 @@ export const postRouter = createTRPCRouter({ where: (posts, { eq }) => eq(posts.id, id), with: { tags: { with: { tag: true } }, + series: true }, }); diff --git a/server/db/schema.ts b/server/db/schema.ts index ce7a53e6..ee87eabd 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -35,6 +35,26 @@ export const sessionRelations = relations(session, ({ one }) => ({ }), })); +export const series = pgTable("Series", { + id: serial("id").primaryKey(), + name: text("name").notNull(), + userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updatedAt", { + precision: 3, + withTimezone: true + }).notNull() + .$onUpdate(() => new Date()) + .default(sql`CURRENT_TIMESTAMP`), +}) + + export const account = pgTable( "account", { @@ -149,6 +169,7 @@ export const post = pgTable( .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), showComments: boolean("showComments").default(true).notNull(), likes: integer("likes").default(0).notNull(), + seriesId: integer("seriesId").references(() => series.id, { onDelete: "set null", onUpdate: "cascade" }), }, (table) => { return { @@ -168,6 +189,7 @@ export const postRelations = relations(post, ({ one, many }) => ({ notifications: many(notification), user: one(user, { fields: [post.userId], references: [user.id] }), tags: many(post_tag), + series: one(series,{ fields: [post.seriesId], references: [series.id] }), })); export const user = pgTable( @@ -273,6 +295,11 @@ export const bookmark = pgTable( }, ); + +export const seriesRelations = relations(series, ({ one, many }) => ({ + posts: many(post), +})); + export const bookmarkRelations = relations(bookmark, ({ one, many }) => ({ post: one(post, { fields: [bookmark.postId], references: [post.id] }), user: one(user, { fields: [bookmark.userId], references: [user.id] }),