From ac342f95023e76c743f55ef89d8c89fe1356dc90 Mon Sep 17 00:00:00 2001 From: mosesintech Date: Fri, 19 Apr 2024 15:10:11 -0500 Subject: [PATCH 1/9] feat(sayings): add submitQuote component --- src/app/apps/sayings/app/submit-quote/page.tsx | 3 ++- .../domain/sayings/user-actions/submit-quote/index.tsx | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/components/domain/sayings/user-actions/submit-quote/index.tsx diff --git a/src/app/apps/sayings/app/submit-quote/page.tsx b/src/app/apps/sayings/app/submit-quote/page.tsx index f9305de..d665f22 100644 --- a/src/app/apps/sayings/app/submit-quote/page.tsx +++ b/src/app/apps/sayings/app/submit-quote/page.tsx @@ -1,4 +1,5 @@ import { unstable_noStore as noStore } from "next/cache"; +import SubmitQuote from "~/components/domain/sayings/user-actions/submit-quote"; export default async function SayingsApp() { noStore(); @@ -12,7 +13,7 @@ export default async function SayingsApp() { Sayings of the Fathers -

Submit New Quote Here

+ ); diff --git a/src/components/domain/sayings/user-actions/submit-quote/index.tsx b/src/components/domain/sayings/user-actions/submit-quote/index.tsx new file mode 100644 index 0000000..412ac81 --- /dev/null +++ b/src/components/domain/sayings/user-actions/submit-quote/index.tsx @@ -0,0 +1,7 @@ +export default function SubmitQuote() { + return ( + <> +
submit quote
+ + ); +} From 23fe23530849af3d6a5796579100cf1ca805f44e Mon Sep 17 00:00:00 2001 From: mosesintech Date: Mon, 22 Apr 2024 09:17:04 -0500 Subject: [PATCH 2/9] feat(sayings): add simple ISBN search with GoogleApi --- .../submit-quote/book-combobox.tsx | 129 ++++++++++++++++++ .../user-actions/submit-quote/index.tsx | 17 ++- src/server/api/routers/works.ts | 110 ++++++++++++--- 3 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx diff --git a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx new file mode 100644 index 0000000..b0ef461 --- /dev/null +++ b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { api } from "~/trpc/react"; + +// import components +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { useToast } from "~/components/ui/use-toast"; + +export default function BookCombobox() { + const [submitError, setSubmitError] = useState(""); + const { toast } = useToast(); + + const formSchema = z.object({ + isbn: z.union([z.string().min(10).max(10), z.string().min(13).max(13)]), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + isbn: "", + }, + }); + + const { + formState: { isDirty }, + setValue, + setError, + reset, + } = form; + + const createQuote = api.work.isbnSearch.useMutation({ + onSuccess: (data) => { + toast({ + title: `Success`, + description: `${data?.volumeInfo.title} has been submitted!`, + }); + return reset(); + }, + onError: (e) => { + return setSubmitError(e.message); + }, + }); + + function onSubmit(formData: z.infer) { + createQuote.mutate({ + isbn: formData.isbn, + }); + } + + return ( + <> + + + + + + + Report Quote + + Bring a quote to the attention of our moderation team. + + + +
+ + ( + <> + + + Add Book via ISBN + + + + + + + + )} + /> + + + + + + +
+
+ + ); +} diff --git a/src/components/domain/sayings/user-actions/submit-quote/index.tsx b/src/components/domain/sayings/user-actions/submit-quote/index.tsx index 8aa07d7..7aa5f9b 100644 --- a/src/components/domain/sayings/user-actions/submit-quote/index.tsx +++ b/src/components/domain/sayings/user-actions/submit-quote/index.tsx @@ -20,6 +20,7 @@ import { import { useToast } from "~/components/ui/use-toast"; import { Textarea } from "~/components/ui/textarea"; import SaintCombobox from "~/components/domain/common/saint-combobox"; +import BookCombobox from "./book-combobox"; export default function SubmitQuote() { const [submitError, setSubmitError] = useState(""); @@ -97,7 +98,7 @@ export default function SubmitQuote() { Citation Details - + )} /> + + ( + <> + + Book + + + + + )} + /> diff --git a/src/server/api/routers/works.ts b/src/server/api/routers/works.ts index bbc29d1..0e01bad 100644 --- a/src/server/api/routers/works.ts +++ b/src/server/api/routers/works.ts @@ -3,14 +3,88 @@ import { eq } from "drizzle-orm"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { works } from "~/server/db/schema"; - + +interface GoogleBooksApiItem { + kind: string; + id: string; + etag: string; + selfLink: string; + volumeInfo: { + title: string; + authors: string[]; + publishedDate: string; + industryIdentifiers: { type: string; identifier: string }[]; + readingModes: { + text: boolean; + image: boolean; + }; + pageCount: number; + printType: string; + categories: string[]; + maturityRating: string; + allowAnonLogging: boolean; + contentVersion: string; + panelizationSummary: { + containsEpubBubbles: boolean; + containsImageBubbles: boolean; + }; + language: string; + previewLink: string; + infoLink: string; + canonicalVolumeLink: string; + }; + saleInfo: { + country: string; + saleability: string; + isEbook: boolean; + }; + accessInfo: { + country: string; + viewability: string; + embeddable: boolean; + publicDomain: boolean; + textToSpeechPermission: string; + epub: { + isAvailable: boolean; + }; + pdf: { + isAvailable: boolean; + }; + webReaderLink: string; + accessViewStatus: string; + quoteSharingAllowed: boolean; + }; +} + +interface GoogleBooksApi { + kind: string; + totalItems: number; + items: GoogleBooksApiItem[]; +} + export const workRouter = createTRPCRouter({ + isbnSearch: publicProcedure + .input( + z.object({ + isbn: z.union([z.string().min(10).max(10), z.string().min(13).max(13)]), + }), + ) + .mutation(async ({ input }) => { + // const isbn = "9780881416817"; + const url = `https://www.googleapis.com/books/v1/volumes?q=isbn:${input.isbn}`; + + const response = await fetch(url); + const data = (await response.json()) as GoogleBooksApi; + return data.items[0]; + }), create: publicProcedure - .input(z.object({ - title: z.string().min(1), - authorId: z.number(), - publishedDate: z.string().nullable(), - })) + .input( + z.object({ + title: z.string().min(1), + authorId: z.number(), + publishedDate: z.string().nullable(), + }), + ) .mutation(async ({ ctx, input }) => { await ctx.db.insert(works).values({ title: input.title, @@ -19,16 +93,18 @@ export const workRouter = createTRPCRouter({ }); }), updateApproval: publicProcedure // TODO: modProcedure - .input(z.object({ + .input( + z.object({ id: z.number(), - isApproved: z.boolean() - })) - .mutation(async ( { ctx, input }) => { - await ctx.db - .update(works) - .set({ - isApproved: input.isApproved, - }) - .where(eq(works.id, input.id)) - }) + isApproved: z.boolean(), + }), + ) + .mutation(async ({ ctx, input }) => { + await ctx.db + .update(works) + .set({ + isApproved: input.isApproved, + }) + .where(eq(works.id, input.id)); + }), }); From 05dbcf29b79b79ed80a788a1d0ae97d8e249b61a Mon Sep 17 00:00:00 2001 From: mosesintech Date: Mon, 22 Apr 2024 12:52:51 -0500 Subject: [PATCH 3/9] fix(sayings): fix parent form submission when AddBook dialog form is submitted --- .../submit-quote/book-combobox.tsx | 117 ++++++------------ .../user-actions/submit-quote/formSchema.ts | 14 ++- .../user-actions/submit-quote/index.tsx | 28 +++-- 3 files changed, 67 insertions(+), 92 deletions(-) diff --git a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx index b0ef461..c72d6ea 100644 --- a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx +++ b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx @@ -1,9 +1,6 @@ "use client"; import { useState } from "react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; import { api } from "~/trpc/react"; // import components @@ -17,111 +14,77 @@ import { DialogTitle, DialogTrigger, } from "~/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "~/components/ui/form"; import { Input } from "~/components/ui/input"; import { useToast } from "~/components/ui/use-toast"; export default function BookCombobox() { + // isbn search cannot be a form, proper. + // if this form is nested in the larger SubmitQuote form + // submitting this form will trigger submit for that form + // there are "solutions" online but I don't have the patience today + const [isbnValue, setIsbnValue] = useState(""); const [submitError, setSubmitError] = useState(""); + const [addBookOpen, setAddBookOpen] = useState(false); const { toast } = useToast(); - const formSchema = z.object({ - isbn: z.union([z.string().min(10).max(10), z.string().min(13).max(13)]), - }); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - isbn: "", - }, - }); - - const { - formState: { isDirty }, - setValue, - setError, - reset, - } = form; - - const createQuote = api.work.isbnSearch.useMutation({ + const createBook = api.work.isbnSearch.useMutation({ onSuccess: (data) => { toast({ title: `Success`, description: `${data?.volumeInfo.title} has been submitted!`, }); - return reset(); + return setAddBookOpen(false); }, onError: (e) => { - return setSubmitError(e.message); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + return setSubmitError(JSON.parse(e.message)[0].message); }, }); - function onSubmit(formData: z.infer) { - createQuote.mutate({ - isbn: formData.isbn, + function onSubmit(isbn: string) { + createBook.mutate({ + isbn: isbn, }); } return ( <> - + - + - Report Quote + Submit a New Book - Bring a quote to the attention of our moderation team. + Our list of approved books are ever expanding. Help us out by + adding one! -
- - ( - <> - - - Add Book via ISBN - - - - - - - - )} - /> + setIsbnValue(e.target.value)} + /> - - - - - + {submitError && ( +

+ {submitError} +

+ )} + + + +
diff --git a/src/components/domain/sayings/user-actions/submit-quote/formSchema.ts b/src/components/domain/sayings/user-actions/submit-quote/formSchema.ts index da78d44..63f60a8 100644 --- a/src/components/domain/sayings/user-actions/submit-quote/formSchema.ts +++ b/src/components/domain/sayings/user-actions/submit-quote/formSchema.ts @@ -3,8 +3,14 @@ import { z } from "zod"; export const formSchema = z.object({ text: z.string().min(1), saint: z.number(), - publicationCity: z.string().min(1), - publicationYear: z.string().min(1), - pageStart: z.string().min(1), - pageEnd: z.string().min(1), + citation: z.object({ + book: z.object({ + title: z.string(), + authors: z.array(z.string()), + }), + publicationCity: z.string().min(1), + publicationYear: z.string().min(1), + pageStart: z.string().min(1), + pageEnd: z.string().min(1), + }), }); diff --git a/src/components/domain/sayings/user-actions/submit-quote/index.tsx b/src/components/domain/sayings/user-actions/submit-quote/index.tsx index 7aa5f9b..0903fdb 100644 --- a/src/components/domain/sayings/user-actions/submit-quote/index.tsx +++ b/src/components/domain/sayings/user-actions/submit-quote/index.tsx @@ -30,10 +30,16 @@ export default function SubmitQuote() { resolver: zodResolver(formSchema), defaultValues: { text: "", - publicationCity: "", - publicationYear: "", - pageStart: "", - pageEnd: "", + citation: { + book: { + title: "", + authors: [], + }, + publicationCity: "", + publicationYear: "", + pageStart: "", + pageEnd: "", + }, }, }); @@ -61,10 +67,10 @@ export default function SubmitQuote() { createQuote.mutate({ text: formData.text, citation: { - publicationCity: formData.publicationCity, // supplied by ISBN lookup - publicationYear: formData.publicationYear, // supplied by ISBN lookup - pageStart: formData.pageStart, - pageEnd: formData.pageEnd, + publicationCity: formData.citation.publicationCity, // supplied by ISBN lookup + publicationYear: formData.citation.publicationYear, // supplied by ISBN lookup + pageStart: formData.citation.pageStart, + pageEnd: formData.citation.pageEnd, }, }); } @@ -115,7 +121,7 @@ export default function SubmitQuote() { ( <> @@ -131,7 +137,7 @@ export default function SubmitQuote() { ( <> @@ -152,7 +158,7 @@ export default function SubmitQuote() { ( <> From bc1aacd50a4dd5dd9090335adfb409070ff89a3d Mon Sep 17 00:00:00 2001 From: mosesintech Date: Mon, 22 Apr 2024 13:36:34 -0500 Subject: [PATCH 4/9] feat(sayings): add book to db after searching isbn --- .../submit-quote/book-combobox.tsx | 25 +- src/server/api/routers/works.ts | 29 +- src/server/db/schema.ts | 351 ++++++++++-------- 3 files changed, 244 insertions(+), 161 deletions(-) diff --git a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx index c72d6ea..6b4b3a8 100644 --- a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx +++ b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx @@ -27,14 +27,29 @@ export default function BookCombobox() { const [addBookOpen, setAddBookOpen] = useState(false); const { toast } = useToast(); - const createBook = api.work.isbnSearch.useMutation({ + const createBook = api.work.create.useMutation({ onSuccess: (data) => { toast({ title: `Success`, - description: `${data?.volumeInfo.title} has been submitted!`, + description: `${data?.title} has been submitted!`, }); return setAddBookOpen(false); }, + onError: (e) => { + return setSubmitError(e.message); + }, + }); + + const searchIsbn = api.work.isbnSearch.useMutation({ + onSuccess: (data, variables) => { + return createBook.mutate({ + title: data.title, + authors: data.authors, + publishedDate: data.publishedDate, + authorId: null, // TODO: add authorId to connect with saints later + isbn: variables.isbn, + }); + }, onError: (e) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access return setSubmitError(JSON.parse(e.message)[0].message); @@ -42,7 +57,7 @@ export default function BookCombobox() { }); function onSubmit(isbn: string) { - createBook.mutate({ + searchIsbn.mutate({ isbn: isbn, }); } @@ -79,10 +94,10 @@ export default function BookCombobox() { diff --git a/src/server/api/routers/works.ts b/src/server/api/routers/works.ts index 0e01bad..5438d0a 100644 --- a/src/server/api/routers/works.ts +++ b/src/server/api/routers/works.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { works } from "~/server/db/schema"; +import { TRPCError } from "@trpc/server"; interface GoogleBooksApiItem { kind: string; @@ -75,22 +76,44 @@ export const workRouter = createTRPCRouter({ const response = await fetch(url); const data = (await response.json()) as GoogleBooksApi; - return data.items[0]; + const book = data.items[0]; + + if (!book) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Could not find book with the ISBN provided.", + }); + } + + return { + title: book.volumeInfo.title, + authors: book.volumeInfo.authors, + publishedDate: book.volumeInfo.publishedDate, + }; }), create: publicProcedure .input( z.object({ title: z.string().min(1), - authorId: z.number(), - publishedDate: z.string().nullable(), + authors: z.array(z.string()), + isbn: z.union([z.string().min(10).max(10), z.string().min(13).max(13)]), + authorId: z.number().nullable(), + publishedDate: z.string(), }), ) .mutation(async ({ ctx, input }) => { await ctx.db.insert(works).values({ title: input.title, + isbn: input.isbn, authorId: input.authorId, publishedDate: input.publishedDate, }); + + const book = await ctx.db + .select() + .from(works) + .where(eq(works.isbn, input.isbn)); + return book[0]; }), updateApproval: publicProcedure // TODO: modProcedure .input( diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 3f4f65b..5562630 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -20,97 +20,100 @@ import { */ export const createTable = pgTableCreator((name) => `synaxis-app_${name}`); -export const users = createTable( - "users", { - id: varchar('id').primaryKey(), - // roles: user, moderator, administrator - role: varchar('role').notNull().default('user'), - username: varchar("username", { length: 256 }).notNull(), - email: varchar("email", { length: 256 }).notNull(), - emailVerified: boolean("email_verified").default(false).notNull(), - password: varchar("password", { length: 256 }).notNull(), - firstName: varchar("first_name", { length: 256 }), - lastName: varchar("last_name", { length: 256 }), - patron: varchar("patron", { length: 256 }), - bio: varchar("bio", { length: 256 }), - birthday: date("birthday"), - nameday: date("nameday"), - location: varchar("location", { length: 256 }), - jurisdiction: varchar("jurisdiction", { length: 256 }), - denomination: varchar("denomination", { length: 256 }), - sex: varchar("sex", { length: 256 }), - joinedDate: timestamp("joined_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedDate: timestamp("updated_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - isBanned: boolean("is_banned").default(false), - isDeleted: boolean("is_deleted").default(false), - } -) +export const users = createTable("users", { + id: varchar("id").primaryKey(), + // roles: user, moderator, administrator + role: varchar("role").notNull().default("user"), + username: varchar("username", { length: 256 }).notNull(), + email: varchar("email", { length: 256 }).notNull(), + emailVerified: boolean("email_verified").default(false).notNull(), + password: varchar("password", { length: 256 }).notNull(), + firstName: varchar("first_name", { length: 256 }), + lastName: varchar("last_name", { length: 256 }), + patron: varchar("patron", { length: 256 }), + bio: varchar("bio", { length: 256 }), + birthday: date("birthday"), + nameday: date("nameday"), + location: varchar("location", { length: 256 }), + jurisdiction: varchar("jurisdiction", { length: 256 }), + denomination: varchar("denomination", { length: 256 }), + sex: varchar("sex", { length: 256 }), + joinedDate: timestamp("joined_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedDate: timestamp("updated_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + isBanned: boolean("is_banned").default(false), + isDeleted: boolean("is_deleted").default(false), +}); export const sessions = createTable("sessions", { id: varchar("session_id").primaryKey(), - userId: varchar('user_id').references(() => users.id).notNull(), - loginTime: timestamp("login_time").default(sql`CURRENT_TIMESTAMP`).notNull(), + userId: varchar("user_id") + .references(() => users.id) + .notNull(), + loginTime: timestamp("login_time") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), expiresAt: timestamp("expires_at", { - withTimezone: true, - mode: "date" - }).notNull() + withTimezone: true, + mode: "date", + }).notNull(), // ip address? -}) - - -export const emailVerificationCodes = createTable( - "email_verification_codes", - { - id: serial("id").primaryKey(), - userId: varchar("user_id", { length: 21 }).unique().notNull(), - email: varchar("email", { length: 255 }).notNull(), - code: varchar("code", { length: 8 }).notNull(), - expiresAt: timestamp("expires_at", { - withTimezone: true, - mode: "date" - }).notNull(), - }, -); - -export const passwordResetTokens = createTable( - "password_reset_tokens", - { - id: varchar("id", { length: 40 }).primaryKey(), - userId: varchar("user_id", { length: 21 }).notNull(), - expiresAt: timestamp("expires_at", { - withTimezone: true, - mode: "date" - }).notNull(), - }, -); - -export const parishes = createTable( - "parishes", { - id: serial("id").primaryKey(), - name: varchar("name", { length: 256 }).notNull(), - adminId: varchar('admin_id').references(() => users.id).notNull(), - isMonastery: boolean("is_monastery").default(false).notNull(), - jurisdiction: varchar("jurisdiction", { length: 256 }).notNull(), - diocese: varchar("diocese", { length: 256 }).notNull(), - priest: varchar("priest", { length: 256 }), - bishop: varchar("bishop", { length: 256 }).notNull(), - patronalFeast: date("patronal_feast"), - streetAddress: varchar("street_address", { length: 256 }).notNull(), - city: varchar("city", { length: 256 }).notNull(), - state: varchar("state", { length: 256 }).notNull(), - zipCode: varchar("zip_code", { length: 256 }).notNull(), - website: varchar("website", { length: 256 }), - googleCalendarId: varchar("google_calendar_id", { length: 256 }), - isActivated: boolean("is_activated").default(false).notNull(), - createdDate: timestamp("created_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedDate: timestamp("updated_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedBy: varchar('updated_by').references(() => users.id), - isDeleted: boolean("is_deleted").default(false), - } -) +}); + +export const emailVerificationCodes = createTable("email_verification_codes", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 21 }).unique().notNull(), + email: varchar("email", { length: 255 }).notNull(), + code: varchar("code", { length: 8 }).notNull(), + expiresAt: timestamp("expires_at", { + withTimezone: true, + mode: "date", + }).notNull(), +}); + +export const passwordResetTokens = createTable("password_reset_tokens", { + id: varchar("id", { length: 40 }).primaryKey(), + userId: varchar("user_id", { length: 21 }).notNull(), + expiresAt: timestamp("expires_at", { + withTimezone: true, + mode: "date", + }).notNull(), +}); + +export const parishes = createTable("parishes", { + id: serial("id").primaryKey(), + name: varchar("name", { length: 256 }).notNull(), + adminId: varchar("admin_id") + .references(() => users.id) + .notNull(), + isMonastery: boolean("is_monastery").default(false).notNull(), + jurisdiction: varchar("jurisdiction", { length: 256 }).notNull(), + diocese: varchar("diocese", { length: 256 }).notNull(), + priest: varchar("priest", { length: 256 }), + bishop: varchar("bishop", { length: 256 }).notNull(), + patronalFeast: date("patronal_feast"), + streetAddress: varchar("street_address", { length: 256 }).notNull(), + city: varchar("city", { length: 256 }).notNull(), + state: varchar("state", { length: 256 }).notNull(), + zipCode: varchar("zip_code", { length: 256 }).notNull(), + website: varchar("website", { length: 256 }), + googleCalendarId: varchar("google_calendar_id", { length: 256 }), + isActivated: boolean("is_activated").default(false).notNull(), + createdDate: timestamp("created_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedDate: timestamp("updated_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedBy: varchar("updated_by").references(() => users.id), + isDeleted: boolean("is_deleted").default(false), +}); export const saints = createTable("saints", { - id: serial('id').primaryKey(), + id: serial("id").primaryKey(), name: varchar("name", { length: 256 }).notNull(), life: varchar("life"), apostle: varchar("apostle"), @@ -127,121 +130,163 @@ export const saints = createTable("saints", { feastDate: varchar("feast_date").notNull(), // because users can submit saints, they need to be approved before publically consumed. isApproved: boolean("is_approved").default(false).notNull(), - createdDate: timestamp("created_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedDate: timestamp("updated_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedBy: varchar('updated_by').references(() => users.id), + createdDate: timestamp("created_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedDate: timestamp("updated_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedBy: varchar("updated_by").references(() => users.id), isDeleted: boolean("is_deleted").default(false), -}) +}); export const works = createTable("works", { - id: serial('id').primaryKey(), - title: varchar('title').notNull(), - publishedDate: varchar('published_date'), + id: serial("id").primaryKey(), + title: varchar("title").notNull(), + publishedDate: varchar("published_date").notNull(), // a pseudo-author should be created for anthology works and the hymns - authorId: integer("author_id").references(() => saints.id).notNull(), + // authorId should be nullable; most books with quotes aren't written by saints + authorId: integer("author_id").references(() => saints.id), // if a work is written by someone else and it contains a quote from a saint: - authorName: varchar('author_name'), - translatorName: varchar('translator_name'), - editorName: varchar('editor_name'), + authors: varchar("author_name"), + translators: varchar("translator_name"), + editors: varchar("editor_name"), isbn: varchar("isbn"), blurb: varchar("blurb"), // because users can submit works, they need to be approved before publically consumed. isApproved: boolean("is_approved").default(false).notNull(), - createdDate: timestamp("created_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedDate: timestamp("updated_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedBy: varchar('updated_by').references(() => users.id), + createdDate: timestamp("created_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedDate: timestamp("updated_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedBy: varchar("updated_by").references(() => users.id), isDeleted: boolean("is_deleted").default(false), -}) +}); // each quote has a citation export const citations = createTable("citations", { - id: serial('id').primaryKey(), - publicationCity: varchar('publication_city').notNull(), - publicationYear: varchar('publication_year').notNull(), - pageStart: integer('page_start').notNull(), - pageEnd: integer('page_end').notNull(), + id: serial("id").primaryKey(), + publicationCity: varchar("publication_city").notNull(), + publicationYear: varchar("publication_year").notNull(), + pageStart: integer("page_start").notNull(), + pageEnd: integer("page_end").notNull(), // because users can submit citations, they need to be approved before publically consumed. isApproved: boolean("is_approved").default(false).notNull(), - createdDate: timestamp("created_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedDate: timestamp("updated_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedBy: varchar('updated_by').references(() => users.id), + createdDate: timestamp("created_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedDate: timestamp("updated_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedBy: varchar("updated_by").references(() => users.id), isDeleted: boolean("is_deleted").default(false), // dbdesigner includes "pg_pl: varchar" but there are no notes for what it might be. -}) +}); export const quotes = createTable("quotes", { id: serial("id").primaryKey(), - text: varchar('text').notNull(), - authorId: integer('author_id').references(() => saints.id).notNull(), - workId: integer('work_id').references(() => works.id).notNull(), - citationId: integer('citation_id').references(() => citations.id).notNull(), + text: varchar("text").notNull(), + authorId: integer("author_id") + .references(() => saints.id) + .notNull(), + workId: integer("work_id") + .references(() => works.id) + .notNull(), + citationId: integer("citation_id") + .references(() => citations.id) + .notNull(), isScripture: boolean("is_scripture").default(false).notNull(), isPrayer: boolean("is_prayer").default(false).notNull(), // id of the user who submitted the quote. - submitId: varchar('submit_id').references(() => users.id).notNull(), + submitId: varchar("submit_id") + .references(() => users.id) + .notNull(), // because users can submit works, they need to be approved before publically consumed. isApproved: boolean("is_approved").default(false).notNull(), - createdDate: timestamp("created_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedDate: timestamp("updated_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedBy: varchar('updated_by').references(() => users.id), + createdDate: timestamp("created_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedDate: timestamp("updated_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedBy: varchar("updated_by").references(() => users.id), isDeleted: boolean("is_deleted").default(false), -}) +}); // user's collections of quotes export const collections = createTable("collections", { id: serial("id").primaryKey(), - name: varchar('name', { length: 256 }).notNull(), - userId: varchar('user_id').references(() => users.id).notNull(), - createdDate: timestamp("created_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedDate: timestamp("updated_date").default(sql`CURRENT_TIMESTAMP`).notNull(), + name: varchar("name", { length: 256 }).notNull(), + userId: varchar("user_id") + .references(() => users.id) + .notNull(), + createdDate: timestamp("created_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedDate: timestamp("updated_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), isDeleted: boolean("is_deleted").default(false), -}) +}); // many to many quotes/collections table export const quote_collections = createTable("quote_collections", { - collectionId: integer('collection_id').references(() => collections.id).notNull(), - quoteId: integer('quote_id').references(() => quotes.id).notNull(), -}) + collectionId: integer("collection_id") + .references(() => collections.id) + .notNull(), + quoteId: integer("quote_id") + .references(() => quotes.id) + .notNull(), +}); // these are categories of quotes/sayings: e.g. hope, faith, love, etc. -export const categories = createTable('categories', { - id: serial('id').primaryKey(), - name: varchar('name').notNull(), +export const categories = createTable("categories", { + id: serial("id").primaryKey(), + name: varchar("name").notNull(), // because uses can submit works, they need to be approved before publically consumed. isApproved: boolean("is_approved").default(false).notNull(), - createdDate: timestamp("created_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedDate: timestamp("updated_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedBy: varchar('updated_by').references(() => users.id), + createdDate: timestamp("created_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedDate: timestamp("updated_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedBy: varchar("updated_by").references(() => users.id), isDeleted: boolean("is_deleted").default(false), -}) +}); // many to many quotes/categories table -export const quote_categories = createTable('quote_categories', { - quoteId: integer('quote_id').references(() => quotes.id), - categoryId: integer('category_id').references(() => categories.id), -}) +export const quote_categories = createTable("quote_categories", { + quoteId: integer("quote_id").references(() => quotes.id), + categoryId: integer("category_id").references(() => categories.id), +}); // service refers to sayings and calendar apps and all data they own // used for Work/Book cover photos, saint icons, and user profile pictures. -export const service_images = createTable('service_images', { - id: serial('id').primaryKey(), - url: varchar('url').notNull(), - alt: varchar('alt').notNull(), +export const service_images = createTable("service_images", { + id: serial("id").primaryKey(), + url: varchar("url").notNull(), + alt: varchar("alt").notNull(), // because uses can submit works, they need to be approved before publically consumed. isApproved: boolean("is_approved").default(false).notNull(), - createdDate: timestamp("created_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedDate: timestamp("updated_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedBy: varchar('updated_by').references(() => users.id), -}) + createdDate: timestamp("created_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedDate: timestamp("updated_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedBy: varchar("updated_by").references(() => users.id), +}); // icons here refers to Orthodox Iconography depicting saints -export const icons = createTable('icons', { - saintId: integer('saint_id').references(() => saints.id), - iconId: integer('icon_id').references(() => service_images.id), -}) - -export const work_cover = createTable('work_cover', { - workId: integer('work_id').references(() => works.id), - coverId: integer('cover_id').references(() => service_images.id), -}) +export const icons = createTable("icons", { + saintId: integer("saint_id").references(() => saints.id), + iconId: integer("icon_id").references(() => service_images.id), +}); +export const work_cover = createTable("work_cover", { + workId: integer("work_id").references(() => works.id), + coverId: integer("cover_id").references(() => service_images.id), +}); From b09df256c9b861a4d60ad6440efa280bdda85d0f Mon Sep 17 00:00:00 2001 From: mosesintech Date: Tue, 23 Apr 2024 03:23:27 -0500 Subject: [PATCH 5/9] fix(sayings): update authors, translators, and editors in work schema --- src/server/api/routers/works.ts | 2 +- src/server/db/schema.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/api/routers/works.ts b/src/server/api/routers/works.ts index 5438d0a..0845ca5 100644 --- a/src/server/api/routers/works.ts +++ b/src/server/api/routers/works.ts @@ -71,7 +71,6 @@ export const workRouter = createTRPCRouter({ }), ) .mutation(async ({ input }) => { - // const isbn = "9780881416817"; const url = `https://www.googleapis.com/books/v1/volumes?q=isbn:${input.isbn}`; const response = await fetch(url); @@ -107,6 +106,7 @@ export const workRouter = createTRPCRouter({ isbn: input.isbn, authorId: input.authorId, publishedDate: input.publishedDate, + authors: input.authors, }); const book = await ctx.db diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 5562630..00d8742 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -148,9 +148,9 @@ export const works = createTable("works", { // authorId should be nullable; most books with quotes aren't written by saints authorId: integer("author_id").references(() => saints.id), // if a work is written by someone else and it contains a quote from a saint: - authors: varchar("author_name"), - translators: varchar("translator_name"), - editors: varchar("editor_name"), + authors: varchar("authors").array(), + translators: varchar("translators").array(), + editors: varchar("editors").array(), isbn: varchar("isbn"), blurb: varchar("blurb"), // because users can submit works, they need to be approved before publically consumed. From fe10d0d636be5afee1521b851fa169a065c527cc Mon Sep 17 00:00:00 2001 From: mosesintech Date: Tue, 23 Apr 2024 03:49:49 -0500 Subject: [PATCH 6/9] feat(sayings): add coverImage urls to createBook --- .../user-actions/submit-quote/book-combobox.tsx | 1 + src/server/api/routers/works.ts | 17 +++++++++++++++++ src/server/db/schema.ts | 1 + 3 files changed, 19 insertions(+) diff --git a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx index 6b4b3a8..3dfae00 100644 --- a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx +++ b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx @@ -48,6 +48,7 @@ export default function BookCombobox() { publishedDate: data.publishedDate, authorId: null, // TODO: add authorId to connect with saints later isbn: variables.isbn, + coverImage: data.coverImage, }); }, onError: (e) => { diff --git a/src/server/api/routers/works.ts b/src/server/api/routers/works.ts index 0845ca5..25ce181 100644 --- a/src/server/api/routers/works.ts +++ b/src/server/api/routers/works.ts @@ -63,6 +63,10 @@ interface GoogleBooksApi { items: GoogleBooksApiItem[]; } +interface BookCover { + url: string; +} + export const workRouter = createTRPCRouter({ isbnSearch: publicProcedure .input( @@ -84,10 +88,21 @@ export const workRouter = createTRPCRouter({ }); } + // search for book cover url after finding book: + const coverIsbn = + input.isbn.length === 13 && input.isbn.includes("978") + ? `978-${input.isbn.substring(3)}` // if ISBN-13, add hiphen after 978 + : `978-${input.isbn}`; // if ISBN-10, add 978- before number + + const coverUrl = `http://bookcover.longitood.com/bookcover/${coverIsbn}`; + const coverResponse = await fetch(coverUrl); + const coverData = (await coverResponse.json()) as BookCover; + return { title: book.volumeInfo.title, authors: book.volumeInfo.authors, publishedDate: book.volumeInfo.publishedDate, + coverImage: coverData.url, }; }), create: publicProcedure @@ -98,6 +113,7 @@ export const workRouter = createTRPCRouter({ isbn: z.union([z.string().min(10).max(10), z.string().min(13).max(13)]), authorId: z.number().nullable(), publishedDate: z.string(), + coverImage: z.string(), }), ) .mutation(async ({ ctx, input }) => { @@ -107,6 +123,7 @@ export const workRouter = createTRPCRouter({ authorId: input.authorId, publishedDate: input.publishedDate, authors: input.authors, + coverImage: input.coverImage, }); const book = await ctx.db diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 00d8742..17f3ed7 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -152,6 +152,7 @@ export const works = createTable("works", { translators: varchar("translators").array(), editors: varchar("editors").array(), isbn: varchar("isbn"), + coverImage: varchar("cover_image"), blurb: varchar("blurb"), // because users can submit works, they need to be approved before publically consumed. isApproved: boolean("is_approved").default(false).notNull(), From 7df31b8e9a2e301ee5732f034649e33f497cad45 Mon Sep 17 00:00:00 2001 From: mosesintech Date: Thu, 25 Apr 2024 14:03:46 -0500 Subject: [PATCH 7/9] feat(sayings): add publication info using Library of Congress book API to createBook --- package.json | 1 + pnpm-lock.yaml | 14 ++ .../submit-quote/book-combobox.tsx | 7 +- src/server/api/helpers.ts | 139 ++++++++++++++++++ src/server/api/routers/works.ts | 112 +++----------- src/server/api/types.ts | 107 ++++++++++++++ src/server/db/schema.ts | 12 +- 7 files changed, 291 insertions(+), 101 deletions(-) create mode 100644 src/server/api/helpers.ts create mode 100644 src/server/api/types.ts diff --git a/package.json b/package.json index aa956be..77f00d5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "date-fns": "^3.6.0", "drizzle-orm": "^0.29.3", "embla-carousel-react": "^8.0.0", + "fast-xml-parser": "^4.3.6", "framer-motion": "^11.0.5", "html-react-parser": "^5.1.4", "lucia": "^3.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dad731..13bccf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ dependencies: embla-carousel-react: specifier: ^8.0.0 version: 8.0.0(react@18.2.0) + fast-xml-parser: + specifier: ^4.3.6 + version: 4.3.6 framer-motion: specifier: ^11.0.5 version: 11.0.8(react-dom@18.2.0)(react@18.2.0) @@ -4809,6 +4812,13 @@ packages: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true + /fast-xml-parser@4.3.6: + resolution: {integrity: sha512-M2SovcRxD4+vC493Uc2GZVcZaj66CCJhWurC4viynVSTvrpErCShNcDz1lAho6n9REQKvL/ll4A4/fw6Y9z8nw==} + hasBin: true + dependencies: + strnum: 1.0.5 + dev: false + /fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} dependencies: @@ -7180,6 +7190,10 @@ packages: engines: {node: '>=8'} dev: true + /strnum@1.0.5: + resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + dev: false + /style-to-js@1.1.10: resolution: {integrity: sha512-VC7MBJa+y0RZhpnLKDPmVRLRswsASLmixkiZ5R8xZpNT9VyjeRzwnXd2pBzAWdgSGv/pCNNH01gPCCUsB9exYg==} dependencies: diff --git a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx index 3dfae00..e22e413 100644 --- a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx +++ b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx @@ -44,11 +44,14 @@ export default function BookCombobox() { onSuccess: (data, variables) => { return createBook.mutate({ title: data.title, - authors: data.authors, - publishedDate: data.publishedDate, authorId: null, // TODO: add authorId to connect with saints later + authors: data.authors, isbn: variables.isbn, + blurb: data.blurb ?? null, coverImage: data.coverImage, + publisher: data.publisher ?? "Unknown", // TODO: admin dashboard should filter by unknowns to fix + publicationYear: data.publicationYear, + publicationCity: data.publicationCity ?? "Unknown", // TODO: admin dashboard should filter by unknowns to fix }); }, onError: (e) => { diff --git a/src/server/api/helpers.ts b/src/server/api/helpers.ts new file mode 100644 index 0000000..458bf43 --- /dev/null +++ b/src/server/api/helpers.ts @@ -0,0 +1,139 @@ +import { TRPCError } from "@trpc/server"; +import { XMLParser } from "fast-xml-parser"; + +import { + type LocPublisherInfo, + type BookCover, + type GoogleBooksApi, + type LibraryOfCongressBooksApi, + type LocPublisherPlace, +} from "./types"; + +const xmlParser = new XMLParser(); + +export async function isbnSearch(input: { isbn: string }) { + // first get basic book info via google + const url = `https://www.googleapis.com/books/v1/volumes?q=isbn:${input.isbn}`; + + const response = await fetch(url); + const data = (await response.json()) as GoogleBooksApi; + const book = data.items[0]; + + if (!book) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Could not find book with the ISBN provided.", + }); + } + + // next get publisher info via library of congress + const publisherUrl = `http://lx2.loc.gov:210/lcdb?version=1.1&operation=searchRetrieve&query=bath.isbn=${input.isbn}&maximumRecords=1&recordSchema=mods`; + const publisherResponse = await fetch(publisherUrl); + + const publisherData = await publisherResponse.text(); + const parsedPublisherData = xmlParser.parse( + publisherData, + ) as LibraryOfCongressBooksApi; + const publicationInfo = + parsedPublisherData["zs:searchRetrieveResponse"]["zs:records"]["zs:record"][ + "zs:recordData" + ].mods; + + console.log(publicationInfo); + + const bookBlurb = publicationInfo.abstract; + + function publisherInfo() { + const isPublisherAnArray = Array.isArray(publicationInfo.originInfo); + const publicationInfoArray = + publicationInfo.originInfo as LocPublisherInfo[]; + + if (isPublisherAnArray) { + // returns array including items with publisher's name + const publisherArray = publicationInfoArray + .filter((item) => { + return item.agent?.namePart; + }) + .filter((x) => x); + + // returns array including items with publisher's city + const publicationCityArray = publicationInfoArray + .filter((item) => { + return item.agent && item.place; + }) + .filter((x) => x); + + const publisher = publisherArray[0]?.agent?.namePart; + + // because publicationCityArray[0]?.place can be either LocPublisherPlace or an array of it, + // we must check if it is an array before returning publicationCity info. + function cityInfoIfArray() { + const CityArray = publicationCityArray[0]?.place as LocPublisherPlace[]; + const isPublicationCityAnArray = Array.isArray( + publicationCityArray[0]?.place, + ); + + if (isPublicationCityAnArray) { + // return city name if .place is an array + const publicationCity = CityArray[1]?.placeTerm; + return publicationCity; + } + + const cityObject = publicationCityArray[0]?.place as LocPublisherPlace; + + // return city name if .place is not an array + const publicationCity = cityObject.placeTerm; + return publicationCity; + } + + const publicationCity = cityInfoIfArray(); + + return { publisher, publicationCity }; + } + + const publicationInfoObject = + publicationInfo.originInfo as LocPublisherInfo; + const isPublicationCityAnArray = Array.isArray(publicationInfoObject.place); + + function cityInfoIfNotArray() { + const CityArray = publicationInfoObject.place as LocPublisherPlace[]; + + if (isPublicationCityAnArray) { + const publicationCity = CityArray[1]?.placeTerm; + return publicationCity; + } + + const cityObject = publicationInfoObject.place as LocPublisherPlace; + + const publicationCity = cityObject.placeTerm; + return publicationCity; + } + + const publisher = publicationInfoObject.agent?.namePart; + const publicationCity = cityInfoIfNotArray(); + + return { publisher, publicationCity }; + } + + const { publisher, publicationCity } = publisherInfo(); + + // search for book cover url after finding book: + const coverIsbn = + input.isbn.length === 13 && input.isbn.includes("978") + ? `978-${input.isbn.substring(3)}` // if ISBN-13, add hiphen after 978 + : `978-${input.isbn}`; // if ISBN-10, add 978- before number + + const coverUrl = `http://bookcover.longitood.com/bookcover/${coverIsbn}`; + const coverResponse = await fetch(coverUrl); + const coverData = (await coverResponse.json()) as BookCover; + + return { + title: book.volumeInfo.title, + authors: book.volumeInfo.authors, + blurb: bookBlurb, + coverImage: coverData.url, + publisher: publisher, + publicationYear: book.volumeInfo.publishedDate, + publicationCity: publicationCity, + }; +} diff --git a/src/server/api/routers/works.ts b/src/server/api/routers/works.ts index 25ce181..3247789 100644 --- a/src/server/api/routers/works.ts +++ b/src/server/api/routers/works.ts @@ -3,69 +3,7 @@ import { eq } from "drizzle-orm"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { works } from "~/server/db/schema"; -import { TRPCError } from "@trpc/server"; - -interface GoogleBooksApiItem { - kind: string; - id: string; - etag: string; - selfLink: string; - volumeInfo: { - title: string; - authors: string[]; - publishedDate: string; - industryIdentifiers: { type: string; identifier: string }[]; - readingModes: { - text: boolean; - image: boolean; - }; - pageCount: number; - printType: string; - categories: string[]; - maturityRating: string; - allowAnonLogging: boolean; - contentVersion: string; - panelizationSummary: { - containsEpubBubbles: boolean; - containsImageBubbles: boolean; - }; - language: string; - previewLink: string; - infoLink: string; - canonicalVolumeLink: string; - }; - saleInfo: { - country: string; - saleability: string; - isEbook: boolean; - }; - accessInfo: { - country: string; - viewability: string; - embeddable: boolean; - publicDomain: boolean; - textToSpeechPermission: string; - epub: { - isAvailable: boolean; - }; - pdf: { - isAvailable: boolean; - }; - webReaderLink: string; - accessViewStatus: string; - quoteSharingAllowed: boolean; - }; -} - -interface GoogleBooksApi { - kind: string; - totalItems: number; - items: GoogleBooksApiItem[]; -} - -interface BookCover { - url: string; -} +import { isbnSearch } from "../helpers"; export const workRouter = createTRPCRouter({ isbnSearch: publicProcedure @@ -75,34 +13,15 @@ export const workRouter = createTRPCRouter({ }), ) .mutation(async ({ input }) => { - const url = `https://www.googleapis.com/books/v1/volumes?q=isbn:${input.isbn}`; - - const response = await fetch(url); - const data = (await response.json()) as GoogleBooksApi; - const book = data.items[0]; - - if (!book) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Could not find book with the ISBN provided.", - }); - } - - // search for book cover url after finding book: - const coverIsbn = - input.isbn.length === 13 && input.isbn.includes("978") - ? `978-${input.isbn.substring(3)}` // if ISBN-13, add hiphen after 978 - : `978-${input.isbn}`; // if ISBN-10, add 978- before number - - const coverUrl = `http://bookcover.longitood.com/bookcover/${coverIsbn}`; - const coverResponse = await fetch(coverUrl); - const coverData = (await coverResponse.json()) as BookCover; - + const book = await isbnSearch(input); return { - title: book.volumeInfo.title, - authors: book.volumeInfo.authors, - publishedDate: book.volumeInfo.publishedDate, - coverImage: coverData.url, + title: book.title, + authors: book.authors, + blurb: book.blurb, + coverImage: book.coverImage, + publisher: book.publisher, + publicationYear: book.publicationYear, + publicationCity: book.publicationCity, }; }), create: publicProcedure @@ -112,20 +31,27 @@ export const workRouter = createTRPCRouter({ authors: z.array(z.string()), isbn: z.union([z.string().min(10).max(10), z.string().min(13).max(13)]), authorId: z.number().nullable(), - publishedDate: z.string(), coverImage: z.string(), + blurb: z.string().nullable(), + publisher: z.string(), + publicationCity: z.string(), + publicationYear: z.string(), }), ) .mutation(async ({ ctx, input }) => { await ctx.db.insert(works).values({ title: input.title, - isbn: input.isbn, authorId: input.authorId, - publishedDate: input.publishedDate, authors: input.authors, + isbn: input.isbn, + blurb: input.blurb, coverImage: input.coverImage, + publisher: input.publisher, + publicationCity: input.publicationCity, + publicationYear: input.publicationYear, }); + // return for toast success message const book = await ctx.db .select() .from(works) diff --git a/src/server/api/types.ts b/src/server/api/types.ts new file mode 100644 index 0000000..72f3771 --- /dev/null +++ b/src/server/api/types.ts @@ -0,0 +1,107 @@ +interface GoogleBooksVolumeInfo { + title: string; + authors: string[]; + publisher: string; + publishedDate: string; + industryIdentifiers: { type: string; identifier: string }[]; + readingModes: { + text: boolean; + image: boolean; + }; + pageCount: number; + printType: string; + categories: string[]; + maturityRating: string; + allowAnonLogging: boolean; + contentVersion: string; + panelizationSummary: { + containsEpubBubbles: boolean; + containsImageBubbles: boolean; + }; + language: string; + previewLink: string; + infoLink: string; + canonicalVolumeLink: string; +} + +export interface GoogleBooksApiItem { + kind: string; + id: string; + etag: string; + selfLink: string; + volumeInfo: GoogleBooksVolumeInfo; + saleInfo: { + country: string; + saleability: string; + isEbook: boolean; + }; + accessInfo: { + country: string; + viewability: string; + embeddable: boolean; + publicDomain: boolean; + textToSpeechPermission: string; + epub: { + isAvailable: boolean; + }; + pdf: { + isAvailable: boolean; + }; + webReaderLink: string; + accessViewStatus: string; + quoteSharingAllowed: boolean; + }; +} + +export interface GoogleBooksApi { + kind: string; + totalItems: number; + items: GoogleBooksApiItem[]; +} + +export interface BookCover { + url: string; +} + +export interface LibraryOfCongressBooksApi { + "zs:searchRetrieveResponse": { + "zs:records": { + "zs:record": { + "zs:recordData": { + mods: { + titleInfo: { + title: string; + }; + name: LocBookInfo[]; + originInfo: LocPublisherInfo[] | LocPublisherInfo; + abstract?: string; // blurb; includes: "-- Provided by publisher." + }; + }; + }; + }; + }; +} + +type LocBookInfo = { + namePart: string | string[]; + role: { + roleTerm: string; // author, translator + }; +}; + +export type LocPublisherPlace = { + placeTerm: string; // example: 'nyu" or 'Yonkers :' +}; + +export interface LocPublisherInfo { + place: LocPublisherPlace[] | LocPublisherPlace; + agent?: { + namePart: string; // example: "St Vladimir's Seminary Press," + role: { + roleTerm: string; //example: "publisher" + }; + }; + dateIssued: number | string; + issuance?: string; + edition?: string; +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 17f3ed7..12799d4 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -143,17 +143,19 @@ export const saints = createTable("saints", { export const works = createTable("works", { id: serial("id").primaryKey(), title: varchar("title").notNull(), - publishedDate: varchar("published_date").notNull(), // a pseudo-author should be created for anthology works and the hymns // authorId should be nullable; most books with quotes aren't written by saints authorId: integer("author_id").references(() => saints.id), // if a work is written by someone else and it contains a quote from a saint: - authors: varchar("authors").array(), + authors: varchar("authors").array().notNull(), translators: varchar("translators").array(), editors: varchar("editors").array(), - isbn: varchar("isbn"), - coverImage: varchar("cover_image"), + isbn: varchar("isbn").notNull(), blurb: varchar("blurb"), + coverImage: varchar("cover_image"), + publisher: varchar("publisher"), + publicationCity: varchar("publication_city"), + publicationYear: varchar("publication_year").notNull(), // because users can submit works, they need to be approved before publically consumed. isApproved: boolean("is_approved").default(false).notNull(), createdDate: timestamp("created_date") @@ -169,8 +171,6 @@ export const works = createTable("works", { // each quote has a citation export const citations = createTable("citations", { id: serial("id").primaryKey(), - publicationCity: varchar("publication_city").notNull(), - publicationYear: varchar("publication_year").notNull(), pageStart: integer("page_start").notNull(), pageEnd: integer("page_end").notNull(), // because users can submit citations, they need to be approved before publically consumed. From 848ec081f441369820890728db72492ea4ce8e7e Mon Sep 17 00:00:00 2001 From: mosesintech Date: Thu, 25 Apr 2024 14:16:55 -0500 Subject: [PATCH 8/9] feat(sayings): protect createBook and add submitId to capture user id for each submission --- .../sayings/user-actions/submit-quote/book-combobox.tsx | 1 + src/server/api/routers/works.ts | 9 +++++++-- src/server/db/schema.ts | 6 +++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx index e22e413..0a1ebf9 100644 --- a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx +++ b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx @@ -55,6 +55,7 @@ export default function BookCombobox() { }); }, onError: (e) => { + // TODO: duplicate key value violates unique constraint "synaxis-app_works_isbn_unique" to return user friendly error // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access return setSubmitError(JSON.parse(e.message)[0].message); }, diff --git a/src/server/api/routers/works.ts b/src/server/api/routers/works.ts index 3247789..9687f36 100644 --- a/src/server/api/routers/works.ts +++ b/src/server/api/routers/works.ts @@ -1,7 +1,11 @@ import { z } from "zod"; import { eq } from "drizzle-orm"; -import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; +import { + createTRPCRouter, + protectedProcedure, + publicProcedure, +} from "~/server/api/trpc"; import { works } from "~/server/db/schema"; import { isbnSearch } from "../helpers"; @@ -24,7 +28,7 @@ export const workRouter = createTRPCRouter({ publicationCity: book.publicationCity, }; }), - create: publicProcedure + create: protectedProcedure .input( z.object({ title: z.string().min(1), @@ -49,6 +53,7 @@ export const workRouter = createTRPCRouter({ publisher: input.publisher, publicationCity: input.publicationCity, publicationYear: input.publicationYear, + submitId: ctx.user.id, }); // return for toast success message diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 12799d4..7b5dcde 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -150,7 +150,7 @@ export const works = createTable("works", { authors: varchar("authors").array().notNull(), translators: varchar("translators").array(), editors: varchar("editors").array(), - isbn: varchar("isbn").notNull(), + isbn: varchar("isbn").notNull().unique(), blurb: varchar("blurb"), coverImage: varchar("cover_image"), publisher: varchar("publisher"), @@ -158,6 +158,10 @@ export const works = createTable("works", { publicationYear: varchar("publication_year").notNull(), // because users can submit works, they need to be approved before publically consumed. isApproved: boolean("is_approved").default(false).notNull(), + // id of the user who submitted the quote. + submitId: varchar("submit_id") + .references(() => users.id) + .notNull(), createdDate: timestamp("created_date") .default(sql`CURRENT_TIMESTAMP`) .notNull(), From 3d5918fd030603828206fdcd8c8a4383418d5ef4 Mon Sep 17 00:00:00 2001 From: mosesintech Date: Thu, 25 Apr 2024 14:26:58 -0500 Subject: [PATCH 9/9] feat(sayings): add user friendly error message for duplicate isbn submissions --- .../submit-quote/book-combobox.tsx | 1 - src/server/api/routers/works.ts | 35 ++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx index 0a1ebf9..e22e413 100644 --- a/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx +++ b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx @@ -55,7 +55,6 @@ export default function BookCombobox() { }); }, onError: (e) => { - // TODO: duplicate key value violates unique constraint "synaxis-app_works_isbn_unique" to return user friendly error // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access return setSubmitError(JSON.parse(e.message)[0].message); }, diff --git a/src/server/api/routers/works.ts b/src/server/api/routers/works.ts index 9687f36..d09e2f8 100644 --- a/src/server/api/routers/works.ts +++ b/src/server/api/routers/works.ts @@ -8,6 +8,7 @@ import { } from "~/server/api/trpc"; import { works } from "~/server/db/schema"; import { isbnSearch } from "../helpers"; +import { TRPCError } from "@trpc/server"; export const workRouter = createTRPCRouter({ isbnSearch: publicProcedure @@ -43,18 +44,28 @@ export const workRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - await ctx.db.insert(works).values({ - title: input.title, - authorId: input.authorId, - authors: input.authors, - isbn: input.isbn, - blurb: input.blurb, - coverImage: input.coverImage, - publisher: input.publisher, - publicationCity: input.publicationCity, - publicationYear: input.publicationYear, - submitId: ctx.user.id, - }); + await ctx.db + .insert(works) + .values({ + title: input.title, + authorId: input.authorId, + authors: input.authors, + isbn: input.isbn, + blurb: input.blurb, + coverImage: input.coverImage, + publisher: input.publisher, + publicationCity: input.publicationCity, + publicationYear: input.publicationYear, + submitId: ctx.user.id, + }) + .catch((e) => { + if (String(e).includes("synaxis-app_works_isbn_unique")) { + throw new TRPCError({ + code: "UNPROCESSABLE_CONTENT", + message: "We already have this book in our database.", + }); + } + }); // return for toast success message const book = await ctx.db