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 new file mode 100644 index 0000000..e22e413 --- /dev/null +++ b/src/components/domain/sayings/user-actions/submit-quote/book-combobox.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; +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 { 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 createBook = api.work.create.useMutation({ + onSuccess: (data) => { + toast({ + title: `Success`, + 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, + 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) => { + // 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(isbn: string) { + searchIsbn.mutate({ + isbn: isbn, + }); + } + + return ( + <> + + + + + + + Submit a New Book + + Our list of approved books are ever expanding. Help us out by + adding one! + + + + 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 8aa07d7..0903fdb 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(""); @@ -29,10 +30,16 @@ export default function SubmitQuote() { resolver: zodResolver(formSchema), defaultValues: { text: "", - publicationCity: "", - publicationYear: "", - pageStart: "", - pageEnd: "", + citation: { + book: { + title: "", + authors: [], + }, + publicationCity: "", + publicationYear: "", + pageStart: "", + pageEnd: "", + }, }, }); @@ -60,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, }, }); } @@ -97,7 +104,7 @@ export default function SubmitQuote() { Citation Details - + )} /> + + ( + <> + + Book + + + + + )} + /> ( <> @@ -137,7 +158,7 @@ export default function SubmitQuote() { ( <> 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 bbc29d1..d09e2f8 100644 --- a/src/server/api/routers/works.ts +++ b/src/server/api/routers/works.ts @@ -1,34 +1,92 @@ 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"; +import { TRPCError } from "@trpc/server"; + export const workRouter = createTRPCRouter({ - create: publicProcedure - .input(z.object({ - title: z.string().min(1), - authorId: z.number(), - publishedDate: z.string().nullable(), - })) + isbnSearch: publicProcedure + .input( + z.object({ + isbn: z.union([z.string().min(10).max(10), z.string().min(13).max(13)]), + }), + ) + .mutation(async ({ input }) => { + const book = await isbnSearch(input); + return { + title: book.title, + authors: book.authors, + blurb: book.blurb, + coverImage: book.coverImage, + publisher: book.publisher, + publicationYear: book.publicationYear, + publicationCity: book.publicationCity, + }; + }), + create: protectedProcedure + .input( + z.object({ + title: z.string().min(1), + authors: z.array(z.string()), + isbn: z.union([z.string().min(10).max(10), z.string().min(13).max(13)]), + authorId: z.number().nullable(), + 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, - authorId: input.authorId, - publishedDate: input.publishedDate, - }); + 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 + .select() + .from(works) + .where(eq(works.isbn, input.isbn)); + return book[0]; }), 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)); + }), }); 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 3f4f65b..7b5dcde 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,168 @@ 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(), // 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'), - isbn: varchar("isbn"), + authors: varchar("authors").array().notNull(), + translators: varchar("translators").array(), + editors: varchar("editors").array(), + isbn: varchar("isbn").notNull().unique(), 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").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedDate: timestamp("updated_date").default(sql`CURRENT_TIMESTAMP`).notNull(), - updatedBy: varchar('updated_by').references(() => users.id), + // 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(), + 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(), + 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), +});