From f0161e609a3afd3015e37445d2be0fe6531347c8 Mon Sep 17 00:00:00 2001 From: Penumarthi Navaneeth Date: Thu, 17 Oct 2024 21:18:38 +0530 Subject: [PATCH 1/3] Feature/ Add series to link related articles --- app/(app)/create/[[...paramsArr]]/_client.tsx | 31 ++++- drizzle/0011_add_series_update_post.sql | 12 ++ schema/post.ts | 2 + schema/series.ts | 6 + server/api/router/index.ts | 3 + server/api/router/post.ts | 16 ++- server/api/router/series.ts | 114 ++++++++++++++++++ server/db/schema.ts | 20 +++ 8 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 drizzle/0011_add_series_update_post.sql create mode 100644 schema/series.ts create mode 100644 server/api/router/series.ts diff --git a/app/(app)/create/[[...paramsArr]]/_client.tsx b/app/(app)/create/[[...paramsArr]]/_client.tsx index 1bd2a9fd..7cde674d 100644 --- a/app/(app)/create/[[...paramsArr]]/_client.tsx +++ b/app/(app)/create/[[...paramsArr]]/_client.tsx @@ -160,6 +160,15 @@ const Create = () => { isSuccess, } = api.post.create.useMutation(); + const { mutate: seriesUpdate, status: seriesStatus } = api.series.update.useMutation({ + onError(error) { + // TODO: Add error messages from field validations + console.log("Error updating settings: ", error); + toast.error("Error auto-saving"); + Sentry.captureException(error); + } + }); + // TODO get rid of this for standard get post // Should be allowed get draft post through regular mechanism if you own it const { @@ -215,6 +224,7 @@ const Create = () => { tags, canonicalUrl: data.canonicalUrl || undefined, excerpt: data.excerpt || removeMarkdown(data.body, {}).substring(0, 155), + seriesName: data.seriesName || undefined }; return formData; }; @@ -226,7 +236,10 @@ const Create = () => { if (!formData.id) { await create({ ...formData }); } else { - await save({ ...formData, id: postId }); + await Promise.all([ + save({ ...formData, id: postId }), + seriesUpdate({postId, seriesName: formData.seriesName}) + ]); toast.success("Saved"); setSavedTime( new Date().toLocaleString(undefined, { @@ -539,10 +552,24 @@ const Create = () => { {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/0011_add_series_update_post.sql b/drizzle/0011_add_series_update_post.sql new file mode 100644 index 00000000..6e8e749c --- /dev/null +++ b/drizzle/0011_add_series_update_post.sql @@ -0,0 +1,12 @@ +-- Create Series table +CREATE TABLE IF NOT EXISTS "Series" ( + "id" SERIAL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL +); +-- Update Post table to add seriesId column +ALTER TABLE "Post" +ADD COLUMN "seriesId" INTEGER \ No newline at end of file diff --git a/schema/post.ts b/schema/post.ts index 224e8940..95bbd1df 100644 --- a/schema/post.ts +++ b/schema/post.ts @@ -25,6 +25,7 @@ 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().optional() }); export const PublishPostSchema = z.object({ @@ -50,6 +51,7 @@ export const ConfirmPostSchema = z.object({ .optional(), canonicalUrl: z.string().trim().url().optional(), tags: z.string().array().max(5).optional(), + seriesName: z.string().trim().optional() }); export const DeletePostSchema = z.object({ diff --git a/schema/series.ts b/schema/series.ts new file mode 100644 index 00000000..e457b4cb --- /dev/null +++ b/schema/series.ts @@ -0,0 +1,6 @@ +import z from "zod"; + +export const UpdateSeriesSchema = z.object({ + postId: z.string(), + seriesName: z.string().trim().optional() +}); \ No newline at end of file diff --git a/server/api/router/index.ts b/server/api/router/index.ts index d7274a63..26709ba3 100644 --- a/server/api/router/index.ts +++ b/server/api/router/index.ts @@ -8,6 +8,8 @@ import { adminRouter } from "./admin"; import { reportRouter } from "./report"; import { tagRouter } from "./tag"; +import { seriesRouter } from "./series"; + export const appRouter = createTRPCRouter({ post: postRouter, profile: profileRouter, @@ -16,6 +18,7 @@ export const appRouter = createTRPCRouter({ admin: adminRouter, report: reportRouter, tag: tagRouter, + series: seriesRouter }); // export type definition of API diff --git a/server/api/router/post.ts b/server/api/router/post.ts index 8a41482b..19b3a4bd 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, @@ -192,6 +192,19 @@ export const postRouter = createTRPCRouter({ .where(eq(post.id, id)) .returning(); + if(deletedPost.seriesId){ + // check is there is any other post with the current seriesId + const anotherPostInThisSeries = await ctx.db.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 ctx.db.delete(series).where(eq(series.id, deletedPost.seriesId)); + } + } + return deletedPost; }), like: protectedProcedure @@ -428,6 +441,7 @@ export const postRouter = createTRPCRouter({ where: (posts, { eq }) => eq(posts.id, id), with: { tags: { with: { tag: true } }, + series: true }, }); diff --git a/server/api/router/series.ts b/server/api/router/series.ts new file mode 100644 index 00000000..c78eca1a --- /dev/null +++ b/server/api/router/series.ts @@ -0,0 +1,114 @@ +import { TRPCError } from "@trpc/server"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { series, post } from "@/server/db/schema"; +import { UpdateSeriesSchema } from "@/schema/series"; +import {eq} from "drizzle-orm"; +export const seriesRouter = createTRPCRouter({ + update: protectedProcedure + .input(UpdateSeriesSchema) + .mutation(async ({input, ctx}) => { + const {postId, seriesName} = input; + console.group("series Name: ", seriesName); + const currentPost = await ctx.db.query.post.findFirst({ + columns: { + id: true, + seriesId: true, + userId: true + }, + with: { + series: { + columns: { + id: true, + title: true + }, + }, + }, + where: (post, { eq }) => eq(post.id, postId), + }); + if (currentPost?.userId !== ctx.session.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + const createNewSeries = async (seriesTitle: string) => { + // check if a series with that name already exists + // or else create a new one + let seriesId : number; + const currSeries = await ctx.db.query.series.findFirst({ + columns: { + id: true + }, + where: (series, { eq }) => eq(series.title, seriesTitle), + }) + if(!currSeries){ + const [newSeries] = await ctx.db.insert(series).values({ + title: seriesTitle, + userId: ctx.session.user.id, + updatedAt: new Date() + }).returning(); + + seriesId = newSeries.id; + } + else{ + seriesId = currSeries.id; + } + // update that series id in the current post + await ctx.db + .update(post) + .set({ + seriesId: seriesId + }) + .where(eq(post.id, currentPost.id)); + } + const unlinkSeries = async (seriesId: number) => { + // Check if the user has added a another post with the same series id previously + const anotherPostInThisSeries = await ctx.db.query.post.findFirst({ + where: (post, { eq, and, ne }) => + and ( + ne(post.id, currentPost.id), + eq(post.seriesId, currentPost.seriesId!) + ) + }) + // if another post with the same seriesId is present, then do nothing + // else remove the series from the series table + if(!anotherPostInThisSeries){ + await ctx.db.delete(series).where(eq(series.id, seriesId)); + } + // update that series id in the current post + await ctx.db + .update(post) + .set({ + seriesId: null + }) + .where(eq(post.id, currentPost.id)); + } + + if(seriesName){ + // check if the current post is already linked to a series + if(currentPost?.seriesId){ + // check if the series title is same as the current series name + // then we do nothing + if(currentPost?.series?.title !== seriesName){ + // then the user has updated the series name in this particular edit + // Check if there is another post with the same title, else delete the series + // and create a new post with the new series name + // and update that new series id in the post + await unlinkSeries(currentPost.seriesId); + await createNewSeries(seriesName); + } + } + else{ + // the current post is not yet linked to a seriesId + // so create a new series and put that Id in the post + await createNewSeries(seriesName); + } + } + else{ + // either the user has not added the series Name (We do nothing) + // or while editing the post, the user has removed the series name + if(currentPost.seriesId !== null){ + await unlinkSeries(currentPost.seriesId); + } + } + }) +}) \ No newline at end of file diff --git a/server/db/schema.ts b/server/db/schema.ts index ce7a53e6..bb59b6d0 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -35,6 +35,24 @@ export const sessionRelations = relations(session, ({ one }) => ({ }), })); +export const series = pgTable("Series", { + id: serial("id").primaryKey(), + title: text("title").notNull(), + description: text("description"), + userId: text("userId"), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updatedAt", { + precision: 3, + withTimezone: true + }).notNull() +}) + export const account = pgTable( "account", { @@ -149,6 +167,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") }, (table) => { return { @@ -168,6 +187,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( From 75400e5dcffed7afa4219418526166de7c757a11 Mon Sep 17 00:00:00 2001 From: Penumarthi Navaneeth Date: Thu, 17 Oct 2024 23:37:21 +0530 Subject: [PATCH 2/3] Code improvements: Feature/ Add series to link related articles --- app/(app)/create/[[...paramsArr]]/_client.tsx | 22 ++-- drizzle/0011_add_series_update_post.sql | 6 +- server/api/router/post.ts | 38 ++++--- server/api/router/series.ts | 103 ++++++++++-------- server/db/schema.ts | 14 ++- 5 files changed, 111 insertions(+), 72 deletions(-) diff --git a/app/(app)/create/[[...paramsArr]]/_client.tsx b/app/(app)/create/[[...paramsArr]]/_client.tsx index 7cde674d..afa6b45d 100644 --- a/app/(app)/create/[[...paramsArr]]/_client.tsx +++ b/app/(app)/create/[[...paramsArr]]/_client.tsx @@ -162,8 +162,6 @@ const Create = () => { const { mutate: seriesUpdate, status: seriesStatus } = api.series.update.useMutation({ onError(error) { - // TODO: Add error messages from field validations - console.log("Error updating settings: ", error); toast.error("Error auto-saving"); Sentry.captureException(error); } @@ -236,11 +234,19 @@ const Create = () => { if (!formData.id) { await create({ ...formData }); } else { - await Promise.all([ - save({ ...formData, id: postId }), - seriesUpdate({postId, seriesName: formData.seriesName}) - ]); - toast.success("Saved"); + try { + await save({ ...formData, id: postId }); + } catch (error) { + toast.error("Error saving post."); + Sentry.captureException(error); + } + try { + await seriesUpdate({ postId, seriesName: formData.seriesName }); + toast.success("Saved"); + } catch (error) { + toast.error("Error updating series."); + Sentry.captureException(error); + } setSavedTime( new Date().toLocaleString(undefined, { dateStyle: "medium", @@ -556,7 +562,7 @@ const Create = () => { Share this link with others to preview your draft. Anyone with the link can view your draft.

- + diff --git a/drizzle/0011_add_series_update_post.sql b/drizzle/0011_add_series_update_post.sql index 6e8e749c..bd4e72a8 100644 --- a/drizzle/0011_add_series_update_post.sql +++ b/drizzle/0011_add_series_update_post.sql @@ -9,4 +9,8 @@ CREATE TABLE IF NOT EXISTS "Series" ( ); -- Update Post table to add seriesId column ALTER TABLE "Post" -ADD COLUMN "seriesId" INTEGER \ No newline at end of file +ADD COLUMN "seriesId" INTEGER +ADD CONSTRAINT fk_post_series + FOREIGN KEY ("seriesId") + REFERENCES "Series" ("id") + ON DELETE SET NULL; \ No newline at end of file diff --git a/server/api/router/post.ts b/server/api/router/post.ts index 19b3a4bd..783d2704 100644 --- a/server/api/router/post.ts +++ b/server/api/router/post.ts @@ -187,25 +187,29 @@ 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)); + } + } - if(deletedPost.seriesId){ - // check is there is any other post with the current seriesId - const anotherPostInThisSeries = await ctx.db.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 ctx.db.delete(series).where(eq(series.id, deletedPost.seriesId)); - } - } + return deletedPost; + }); - return deletedPost; + return deletedPost; }), like: protectedProcedure .input(LikePostSchema) diff --git a/server/api/router/series.ts b/server/api/router/series.ts index c78eca1a..e4da3bef 100644 --- a/server/api/router/series.ts +++ b/server/api/router/series.ts @@ -8,7 +8,11 @@ export const seriesRouter = createTRPCRouter({ .input(UpdateSeriesSchema) .mutation(async ({input, ctx}) => { const {postId, seriesName} = input; - console.group("series Name: ", seriesName); + + if (seriesName && seriesName.trim() === "") { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Series name cannot be empty' }); + } + const currentPost = await ctx.db.query.post.findFirst({ columns: { id: true, @@ -25,6 +29,10 @@ export const seriesRouter = createTRPCRouter({ }, where: (post, { eq }) => eq(post.id, postId), }); + + if (!currentPost) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } if (currentPost?.userId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN", @@ -33,54 +41,61 @@ export const seriesRouter = createTRPCRouter({ const createNewSeries = async (seriesTitle: string) => { // check if a series with that name already exists // or else create a new one - let seriesId : number; - const currSeries = await ctx.db.query.series.findFirst({ - columns: { - id: true - }, - where: (series, { eq }) => eq(series.title, seriesTitle), - }) - if(!currSeries){ - const [newSeries] = await ctx.db.insert(series).values({ - title: seriesTitle, - userId: ctx.session.user.id, - updatedAt: new Date() - }).returning(); + return await ctx.db.transaction(async (tx) => { + let seriesId : number; + const currSeries = await tx.query.series.findFirst({ + columns: { + id: true + }, + where: (series, { eq }) => eq(series.title, seriesTitle), + }) - seriesId = newSeries.id; - } - else{ - seriesId = currSeries.id; - } - // update that series id in the current post - await ctx.db - .update(post) - .set({ - seriesId: seriesId + if(!currSeries){ + const [newSeries] = await tx.insert(series).values({ + title: seriesTitle, + userId: ctx.session.user.id, + updatedAt: new Date() + }).returning(); + + seriesId = newSeries.id; + } + else{ + seriesId = currSeries.id; + } + // update that series id in the current post + await tx + .update(post) + .set({ + seriesId: seriesId + }) + .where(eq(post.id, currentPost.id)); }) - .where(eq(post.id, currentPost.id)); + } + const unlinkSeries = async (seriesId: number) => { // Check if the user has added a another post with the same series id previously - const anotherPostInThisSeries = await ctx.db.query.post.findFirst({ - where: (post, { eq, and, ne }) => - and ( - ne(post.id, currentPost.id), - eq(post.seriesId, currentPost.seriesId!) - ) - }) - // if another post with the same seriesId is present, then do nothing - // else remove the series from the series table - if(!anotherPostInThisSeries){ - await ctx.db.delete(series).where(eq(series.id, seriesId)); - } - // update that series id in the current post - await ctx.db - .update(post) - .set({ - seriesId: null - }) - .where(eq(post.id, currentPost.id)); + 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, currentPost.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, seriesId)); + } + // update that series id in the current post + await tx + .update(post) + .set({ + seriesId: null + }) + .where(eq(post.id, currentPost.id)); + }) } if(seriesName){ diff --git a/server/db/schema.ts b/server/db/schema.ts index bb59b6d0..10bed9e4 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -39,7 +39,7 @@ export const series = pgTable("Series", { id: serial("id").primaryKey(), title: text("title").notNull(), description: text("description"), - userId: text("userId"), + userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), createdAt: timestamp("createdAt", { precision: 3, mode: "string", @@ -51,6 +51,8 @@ export const series = pgTable("Series", { precision: 3, withTimezone: true }).notNull() + .$onUpdate(() => new Date()) + .default(sql`CURRENT_TIMESTAMP`), }) export const account = pgTable( @@ -167,7 +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") + seriesId: integer("seriesId").references(() => series.id, { onDelete: "set null", onUpdate: "cascade" }), }, (table) => { return { @@ -293,6 +295,14 @@ export const bookmark = pgTable( }, ); +export const seriesRelations = relations(series, ({ one, many }) => ({ + posts: many(post), + user: one(user, { + fields: [series.userId], + references: [user.id], + }), +})); + 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] }), From ab67084bb45a3039850f8902e6bea764f92cf477 Mon Sep 17 00:00:00 2001 From: Penumarthi Navaneeth Date: Fri, 18 Oct 2024 09:02:26 +0530 Subject: [PATCH 3/3] Code improvements: Feature/ Add series to link related articles --- app/(app)/create/[[...paramsArr]]/_client.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/(app)/create/[[...paramsArr]]/_client.tsx b/app/(app)/create/[[...paramsArr]]/_client.tsx index afa6b45d..a0acb018 100644 --- a/app/(app)/create/[[...paramsArr]]/_client.tsx +++ b/app/(app)/create/[[...paramsArr]]/_client.tsx @@ -162,7 +162,7 @@ const Create = () => { const { mutate: seriesUpdate, status: seriesStatus } = api.series.update.useMutation({ onError(error) { - toast.error("Error auto-saving"); + toast.error("Error updating series"); Sentry.captureException(error); } }); @@ -234,19 +234,30 @@ const Create = () => { if (!formData.id) { await create({ ...formData }); } else { + let saveSuccess = false; try { await save({ ...formData, id: postId }); + saveSuccess = true; } catch (error) { toast.error("Error saving post."); Sentry.captureException(error); } + + let seriesUpdateSuccess = false; try { - await seriesUpdate({ postId, seriesName: formData.seriesName }); - toast.success("Saved"); + if(formData?.seriesName){ + await seriesUpdate({ postId, seriesName: formData.seriesName }); + } + seriesUpdateSuccess = true; } catch (error) { toast.error("Error updating series."); Sentry.captureException(error); } + + if(saveSuccess && seriesUpdateSuccess){ + toast.success("Saved"); + } + setSavedTime( new Date().toLocaleString(undefined, { dateStyle: "medium",