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 (
+ <>
+
+ >
+ );
+}
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),
+});