diff --git a/app/(app)/create/[[...paramsArr]]/_client.tsx b/app/(app)/create/[[...paramsArr]]/_client.tsx index 1bd2a9fd..a0acb018 100644 --- a/app/(app)/create/[[...paramsArr]]/_client.tsx +++ b/app/(app)/create/[[...paramsArr]]/_client.tsx @@ -160,6 +160,13 @@ const Create = () => { isSuccess, } = api.post.create.useMutation(); + const { mutate: seriesUpdate, status: seriesStatus } = api.series.update.useMutation({ + onError(error) { + toast.error("Error updating series"); + 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 +222,7 @@ const Create = () => { tags, canonicalUrl: data.canonicalUrl || undefined, excerpt: data.excerpt || removeMarkdown(data.body, {}).substring(0, 155), + seriesName: data.seriesName || undefined }; return formData; }; @@ -226,8 +234,30 @@ const Create = () => { if (!formData.id) { await create({ ...formData }); } else { - await save({ ...formData, id: postId }); - toast.success("Saved"); + 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 { + 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", @@ -539,10 +569,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..bd4e72a8 --- /dev/null +++ b/drizzle/0011_add_series_update_post.sql @@ -0,0 +1,16 @@ +-- 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 +ADD CONSTRAINT fk_post_series + FOREIGN KEY ("seriesId") + REFERENCES "Series" ("id") + ON DELETE SET NULL; \ 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..783d2704 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, @@ -187,12 +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)); + } + } + + return deletedPost; + }); - return deletedPost; + return deletedPost; }), like: protectedProcedure .input(LikePostSchema) @@ -428,6 +445,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..0df3ac37 --- /dev/null +++ b/server/api/router/series.ts @@ -0,0 +1,137 @@ +import { TRPCError } from "@trpc/server"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { series, post } from "@/server/db/schema"; +import { UpdateSeriesSchema } from "@/schema/series"; +import {eq, and} from "drizzle-orm"; +export const seriesRouter = createTRPCRouter({ + update: protectedProcedure + .input(UpdateSeriesSchema) + .mutation(async ({input, ctx}) => { + const {postId, seriesName} = input; + + 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, + seriesId: true, + userId: true + }, + with: { + series: { + columns: { + id: true, + title: true + }, + }, + }, + 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", + }); + } + const createNewSeries = async (seriesTitle: string) => { + // check if a series with that name already exists + // or else create a new one + 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.title, seriesTitle), + eq(series.userId, ctx.session.user.id) + ), + }) + + 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)); + }) + + } + + const unlinkSeries = async (seriesId: number) => { + // Check if the user has added a another post with the same series id previously + 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 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( + 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){ + // 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..10bed9e4 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(), + title: text("title").notNull(), + description: text("description"), + 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,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] }),