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] }),