From 88d7d6c7b1a64771e1b78787856f218a136479b5 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Fri, 31 Jan 2025 00:35:44 -0500 Subject: [PATCH] more stuff --- package.json | 3 + pnpm-lock.yaml | 25 +- src/app/(main)/util.ts | 26 ++ src/app/Builder/page.tsx | 20 +- src/app/Products/accessories/page.tsx | 23 ++ .../admin/UsersTable/ButtonOnClick.tsx | 23 ++ src/components/admin/UsersTable/index.tsx | 37 +- src/components/footer/index.tsx | 9 +- src/db/wdcStarter/index.ts | 20 + src/db/wdcStarter/schema.ts | 354 ++++++++++++++++++ src/drizzle/schema/schema.ts | 12 +- src/lib/wdcStarter/auth.ts | 106 ++++++ src/lib/wdcStarter/session.ts | 58 +++ src/use-cases/types.ts | 21 ++ 14 files changed, 694 insertions(+), 43 deletions(-) create mode 100644 src/app/(main)/util.ts create mode 100644 src/app/Products/accessories/page.tsx create mode 100644 src/components/admin/UsersTable/ButtonOnClick.tsx create mode 100644 src/db/wdcStarter/index.ts create mode 100644 src/db/wdcStarter/schema.ts create mode 100644 src/lib/wdcStarter/auth.ts create mode 100644 src/lib/wdcStarter/session.ts create mode 100644 src/use-cases/types.ts diff --git a/package.json b/package.json index ac4a4d8..f833667 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@mui/styles": "^6.1.7", "@mui/system": "^6.1.7", "@mui/x-data-grid": "^7.22.2", + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0", "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-dropdown-menu": "^2.1.5", "@radix-ui/react-icons": "^1.3.2", @@ -59,6 +61,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-icons": "^5.3.0", + "sha2": "link:@oslojs/crypto/sha2", "sonner": "^1.7.2", "stripe": "^17.6.0", "superjson": "^2.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c78f64..d57328f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: '@mui/x-data-grid': specifier: ^7.22.2 version: 7.24.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.2.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.2.0))(@types/react@18.3.18)(react@18.2.0))(@mui/material@6.4.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.2.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.2.0))(@types/react@18.3.18)(react@18.2.0))(@types/react@18.3.18)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mui/system@6.4.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.2.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.2.0))(@types/react@18.3.18)(react@18.2.0))(@types/react@18.3.18)(react@18.2.0))(@types/react@18.3.18)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@oslojs/crypto': + specifier: ^1.0.1 + version: 1.0.1 + '@oslojs/encoding': + specifier: ^1.1.0 + version: 1.1.0 '@radix-ui/react-alert-dialog': specifier: ^1.1.5 version: 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -158,6 +164,9 @@ importers: react-icons: specifier: ^5.3.0 version: 5.4.0(react@18.2.0) + sha2: + specifier: link:@oslojs/crypto/sha2 + version: link:@oslojs/crypto/sha2 sonner: specifier: ^1.7.2 version: 1.7.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -6721,8 +6730,8 @@ snapshots: '@typescript-eslint/parser': 8.21.0(eslint@8.57.1)(typescript@5.7.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.1.0(eslint@8.57.1) @@ -6741,7 +6750,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 @@ -6753,22 +6762,22 @@ snapshots: is-glob: 4.0.3 stable-hash: 0.0.4 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.21.0(eslint@8.57.1)(typescript@5.7.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import@2.31.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -6779,7 +6788,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/src/app/(main)/util.ts b/src/app/(main)/util.ts new file mode 100644 index 0000000..e9ba96c --- /dev/null +++ b/src/app/(main)/util.ts @@ -0,0 +1,26 @@ +export const AUTHENTICATION_ERROR_MESSAGE = + "You must be logged in to view this content"; + +export const PRIVATE_GROUP_ERROR_MESSAGE = + "You do not have permission to view this group"; + +export const AuthenticationError = class AuthenticationError extends Error { + constructor() { + super(AUTHENTICATION_ERROR_MESSAGE); + this.name = "AuthenticationError"; + } +}; + +export const PrivateGroupAccessError = class PrivateGroupAccessError extends Error { + constructor() { + super(PRIVATE_GROUP_ERROR_MESSAGE); + this.name = "PrivateGroupAccessError"; + } +}; + +export const NotFoundError = class NotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } +}; \ No newline at end of file diff --git a/src/app/Builder/page.tsx b/src/app/Builder/page.tsx index 827d13b..085146b 100644 --- a/src/app/Builder/page.tsx +++ b/src/app/Builder/page.tsx @@ -16,24 +16,28 @@ const partsData = [ }, { name: "Lower Parts Kit", + link: "/lowers", source: "-", price: "-", ship_price: "-", }, { name: "Lower Parts Kit", + link: "/lowers", source: "-", price: "-", ship_price: "-", }, { name: "Lower Parts Kit", + link: "/lowers", source: "-", price: "-", ship_price: "-", }, { name: "Lower Parts Kit", + link: "/lowers", source: "-", price: "-", ship_price: "-", @@ -43,15 +47,11 @@ const partsData = [ { group: "Upper Parts", parts: [ - { name: "Upper Reciever", source: "-", price: "-", ship_price: "-" }, - { name: "Barrel", source: "-", price: "-", ship_price: "-" }, - { name: "BCG", source: "-", price: "-", ship_price: "-" }, - { name: "Muzzle Device", source: "-", price: "-", ship_price: "-" }, - { - name: "Charging Handle", - source: "-", - price: "-", - ship_price: "-", + { name: "Upper Reciever", link: "/lowers", source: "-", price: "-", ship_price: "-" }, + { name: "Barrel", link: "/lowers", source: "-", price: "-", ship_price: "-" }, + { name: "BCG", link: "/lowers", source: "-", price: "-", ship_price: "-" }, + { name: "Muzzle Device", link: "/lowers", source: "-", price: "-", ship_price: "-" }, + { name: "Charging Handle", link: "/lowers", source: "-", price: "-", ship_price: "-", }, ], }, @@ -142,7 +142,7 @@ export default function BuilderPage() { aria-hidden="true" className="-ml-0.5 size-5" /> - Purchase123 + Purchase diff --git a/src/app/Products/accessories/page.tsx b/src/app/Products/accessories/page.tsx new file mode 100644 index 0000000..6e7ca9a --- /dev/null +++ b/src/app/Products/accessories/page.tsx @@ -0,0 +1,23 @@ +import { getProductType } from "@queries/PSA"; +import styles from '../styles.module.css'; +import PageHero from "@components/PageHero"; +import SortTable from "@components/SortTable"; +import { Suspense } from "react"; +import Loading from "@src/components/Loading/loading"; + +export default async function BarrelsPage() { + const data = await getProductType('Barrels'); + + return ( +
+ + +
+ + + +
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/admin/UsersTable/ButtonOnClick.tsx b/src/components/admin/UsersTable/ButtonOnClick.tsx new file mode 100644 index 0000000..f3f7787 --- /dev/null +++ b/src/components/admin/UsersTable/ButtonOnClick.tsx @@ -0,0 +1,23 @@ +"use client"; + +import PlusCircleIcon from "@heroicons/react/24/outline/PlusCircleIcon"; + +export default async function ButtonOnClick(props:any) { + const handleClick = async () => { + alert("This feature is coming soon"); + + } + return ( + + ) +} \ No newline at end of file diff --git a/src/components/admin/UsersTable/index.tsx b/src/components/admin/UsersTable/index.tsx index de7b977..04e5911 100644 --- a/src/components/admin/UsersTable/index.tsx +++ b/src/components/admin/UsersTable/index.tsx @@ -3,12 +3,17 @@ import { PlusCircleIcon } from "@heroicons/react/20/solid"; import Image from "next/image"; import Link from "next/link"; +import ButtonOnClick from "./ButtonOnClick"; export default async function UsersTable(props: any) { + + const onClick = () => { + alert("This feature is coming soon"); +} return (
- +
@@ -20,7 +25,7 @@ export default async function UsersTable(props: any) { className="py-3.5 pl-4 pr-3 text-left text-xs font-semibold text-gray-900 " > - {props.newColumnHeadings.getHeading()} + {props.newColumnHeadings.getHeading()} - {props.newColumnHeadings.getHeading()} + {props.newColumnHeadings.getHeading()} - {props.newColumnHeadings.getHeading()} + {props.newColumnHeadings.getHeading()} - {props.newColumnHeadings.getHeading()} + {props.newColumnHeadings.getHeading()}
diff --git a/src/components/footer/index.tsx b/src/components/footer/index.tsx index 996785b..cd95812 100644 --- a/src/components/footer/index.tsx +++ b/src/components/footer/index.tsx @@ -1,4 +1,5 @@ -import Link from "next/link" +import Link from "next/link"; +import constants from "@/lib/constants"; const navigation = { armory: [ @@ -6,7 +7,7 @@ const navigation = { { name: 'Lowers', href: '/Products/lowers' }, { name: 'Uppers', href: '/Products/uppers' }, { name: 'Optics', href: '/Products/optics' }, - { name: 'Accessories', href: '/Products/accessories#' }, + { name: 'Accessories', href: '/Products/accessories' }, ], admin: [ { name: 'Users', href: '/Admin/Users' }, @@ -82,6 +83,8 @@ const navigation = { } export default function Footer() { + let newDate = new Date(); + let year = newDate.getFullYear(); return (
@@ -193,7 +196,7 @@ export default function Footer() { ))}

- © 2024 Your Company, Inc. All rights reserved. + © `{year} {constants.COMPANY_NAME}` All rights reserved.

diff --git a/src/db/wdcStarter/index.ts b/src/db/wdcStarter/index.ts new file mode 100644 index 0000000..2eed658 --- /dev/null +++ b/src/db/wdcStarter/index.ts @@ -0,0 +1,20 @@ +import { env } from "@/env"; +import * as schema from "@schemas/schema"; +import { PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +let database: PostgresJsDatabase; +let pg: ReturnType; + +if (env.NODE_ENV === "production") { + pg = postgres(env.DATABASE_URL); + database = drizzle(pg, { schema }); +} else { + if (!(global as any).database!) { + pg = postgres(env.DATABASE_URL); + (global as any).database = drizzle(pg, { schema }); + } + database = (global as any).database; +} + +export { database, pg }; \ No newline at end of file diff --git a/src/db/wdcStarter/schema.ts b/src/db/wdcStarter/schema.ts new file mode 100644 index 0000000..d7b1414 --- /dev/null +++ b/src/db/wdcStarter/schema.ts @@ -0,0 +1,354 @@ +import { relations, sql } from "drizzle-orm"; +import { + boolean, + index, + integer, + pgEnum, + pgTable, + serial, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +export const roleEnum = pgEnum("role", ["member", "admin"]); +export const accountTypeEnum = pgEnum("type", ["email", "google", "github"]); + +export const users = pgTable("gf_user", { + id: serial("id").primaryKey(), + email: text("email").unique(), + emailVerified: timestamp("emailVerified", { mode: "date" }), +}); + +export const accounts = pgTable( + "gf_accounts", + { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + accountType: accountTypeEnum("accountType").notNull(), + githubId: text("githubId").unique(), + googleId: text("googleId").unique(), + password: text("password"), + salt: text("salt"), + }, + (table) => ({ + userIdAccountTypeIdx: index("user_id_account_type_idx").on( + table.userId, + table.accountType + ), + }) +); + +export const magicLinks = pgTable( + "gf_magic_links", + { + id: serial("id").primaryKey(), + email: text("email").notNull().unique(), + token: text("token"), + tokenExpiresAt: timestamp("tokenExpiresAt", { mode: "date" }), + }, + (table) => ({ + tokenIdx: index("magic_links_token_idx").on(table.token), + }) +); + +export const resetTokens = pgTable( + "gf_reset_tokens", + { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }) + .unique(), + token: text("token"), + tokenExpiresAt: timestamp("tokenExpiresAt", { mode: "date" }), + }, + (table) => ({ + tokenIdx: index("reset_tokens_token_idx").on(table.token), + }) +); + +export const verifyEmailTokens = pgTable( + "gf_verify_email_tokens", + { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }) + .unique(), + token: text("token"), + tokenExpiresAt: timestamp("tokenExpiresAt", { mode: "date" }), + }, + (table) => ({ + tokenIdx: index("verify_email_tokens_token_idx").on(table.token), + }) +); + +export const profiles = pgTable("gf_profile", { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }) + .unique(), + displayName: text("displayName"), + imageId: text("imageId"), + image: text("image"), + bio: text("bio").notNull().default(""), +}); + +export const sessions = pgTable( + "gf_session", + { + id: text("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expiresAt: timestamp("expires_at", { + withTimezone: true, + mode: "date", + }).notNull(), + }, + (table) => ({ + userIdIdx: index("sessions_user_id_idx").on(table.userId), + }) +); + +export const subscriptions = pgTable( + "gf_subscriptions", + { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }) + .unique(), + stripeSubscriptionId: text("stripeSubscriptionId").notNull(), + stripeCustomerId: text("stripeCustomerId").notNull(), + stripePriceId: text("stripePriceId").notNull(), + stripeCurrentPeriodEnd: timestamp("expires", { mode: "date" }).notNull(), + }, + (table) => ({ + stripeSubscriptionIdIdx: index( + "subscriptions_stripe_subscription_id_idx" + ).on(table.stripeSubscriptionId), + }) +); + +export const following = pgTable( + "gf_following", + { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + foreignUserId: serial("foreignUserId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + userIdForeignUserIdIdx: index("following_user_id_foreign_user_id_idx").on( + table.userId, + table.foreignUserId + ), + }) +); + +/** + * newsletters - although the emails for the newsletter are tracked in Resend, it's beneficial to also track + * sign ups in your own database in case you decide to move to another email provider. + * The last thing you'd want is for your email list to get lost due to a + * third party provider shutting down or dropping your data. + */ +export const newsletters = pgTable("gf_newsletter", { + id: serial("id").primaryKey(), + email: text("email").notNull().unique(), +}); + +export const groups = pgTable( + "gf_group", + { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description").notNull(), + isPublic: boolean("isPublic").notNull().default(false), + bannerId: text("bannerId"), + info: text("info").default(""), + youtubeLink: text("youtubeLink").default(""), + discordLink: text("discordLink").default(""), + githubLink: text("githubLink").default(""), + xLink: text("xLink").default(""), + }, + (table) => ({ + userIdIsPublicIdx: index("groups_user_id_is_public_idx").on( + table.userId, + table.isPublic + ), + }) +); + +export const memberships = pgTable( + "gf_membership", + { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + groupId: serial("groupId") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + role: roleEnum("role").default("member"), + }, + (table) => ({ + userIdGroupIdIdx: index("memberships_user_id_group_id_idx").on( + table.userId, + table.groupId + ), + }) +); + +export const invites = pgTable("gf_invites", { + id: serial("id").primaryKey(), + token: text("token") + .notNull() + .default(sql`gen_random_uuid()`) + .unique(), + tokenExpiresAt: timestamp("tokenExpiresAt", { mode: "date" }), + groupId: serial("groupId") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + tokenExpiresAt: timestamp("tokenExpiresAt", { mode: "date" }).notNull(), +}); + +export const events = pgTable("gf_events", { + id: serial("id").primaryKey(), + groupId: serial("groupId") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description").notNull(), + imageId: text("imageId"), + startsOn: timestamp("startsOn", { mode: "date" }).notNull(), +}); + +export const notifications = pgTable("gf_notifications", { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + groupId: serial("groupId") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + postId: integer("postId"), + isRead: boolean("isRead").notNull().default(false), + type: text("type").notNull(), + message: text("message").notNull(), + createdOn: timestamp("createdOn", { mode: "date" }).notNull(), +}); + +export const posts = pgTable("gf_posts", { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + groupId: serial("groupId") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + title: text("title").notNull(), + message: text("message").notNull(), + createdOn: timestamp("createdOn", { mode: "date" }).notNull(), +}); + +export const reply = pgTable( + "gf_replies", + { + id: serial("id").primaryKey(), + userId: serial("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + postId: serial("postId") + .notNull() + .references(() => posts.id, { onDelete: "cascade" }), + groupId: serial("groupId") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + message: text("message").notNull(), + createdOn: timestamp("createdOn", { mode: "date" }).notNull(), + }, + (table) => ({ + postIdIdx: index("replies_post_id_idx").on(table.postId), + }) +); + +/** + * RELATIONSHIPS + * + * Here you can define drizzle relationships between table which helps improve the type safety + * in your code. + */ + +export const groupRelations = relations(groups, ({ many }) => ({ + memberships: many(memberships), +})); + +export const membershipRelations = relations(memberships, ({ one }) => ({ + user: one(users, { fields: [memberships.userId], references: [users.id] }), + profile: one(profiles, { + fields: [memberships.userId], + references: [profiles.userId], + }), + group: one(groups, { + fields: [memberships.groupId], + references: [groups.id], + }), +})); + +export const postsRelationships = relations(posts, ({ one }) => ({ + user: one(users, { fields: [posts.userId], references: [users.id] }), + group: one(groups, { fields: [posts.groupId], references: [groups.id] }), +})); + +export const followingRelationship = relations(following, ({ one }) => ({ + foreignProfile: one(profiles, { + fields: [following.foreignUserId], + references: [profiles.userId], + }), + userProfile: one(profiles, { + fields: [following.userId], + references: [profiles.userId], + }), +})); + +/** + * TYPES + * + * You can create and export types from your schema to use in your application. + * This is useful when you need to know the shape of the data you are working with + * in a component or function. + */ +export type Subscription = typeof subscriptions.$inferSelect; +export type Group = typeof groups.$inferSelect; +export type NewGroup = typeof groups.$inferInsert; +export type Membership = typeof memberships.$inferSelect; + +export type Event = typeof events.$inferSelect; +export type NewEvent = typeof events.$inferInsert; + +export type User = typeof users.$inferSelect; +export type Profile = typeof profiles.$inferSelect; + +export type Notification = typeof notifications.$inferSelect; + +export type Post = typeof posts.$inferSelect; +export type NewPost = typeof posts.$inferInsert; + +export type Reply = typeof reply.$inferSelect; +export type NewReply = typeof reply.$inferInsert; + +export type Following = typeof following.$inferSelect; + +export type GroupId = Group["id"]; + +export type Session = typeof sessions.$inferSelect; \ No newline at end of file diff --git a/src/drizzle/schema/schema.ts b/src/drizzle/schema/schema.ts index 8f49e5b..432c5d1 100644 --- a/src/drizzle/schema/schema.ts +++ b/src/drizzle/schema/schema.ts @@ -532,4 +532,14 @@ export const sessions = pgTable( })); export type Post = typeof posts.$inferSelect; - export type NewPost = typeof posts.$inferInsert; \ No newline at end of file + export type NewPost = typeof posts.$inferInsert; + + export const vwUserSessions = pgView("vw_user_sessions", { id: varchar({ length: 255 }), + userId: varchar("user_id", { length: 21 }), + uId: varchar("u_id", { length: 21 }), + uEmail: varchar("u_email", { length: 255 }), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }), + createdAt: timestamp("created_at", { mode: 'string' }), + updatedAt: timestamp("updated_at", { mode: 'string' }), +}).existing(); +//as(sql`SELECT s.id, s.user_id, u.id AS u_id, u.email AS u_email, s.expires_at, s.created_at, s.updated_at FROM sessions s, users u WHERE s.user_id::text = u.id::text`); \ No newline at end of file diff --git a/src/lib/wdcStarter/auth.ts b/src/lib/wdcStarter/auth.ts new file mode 100644 index 0000000..6742ed1 --- /dev/null +++ b/src/lib/wdcStarter/auth.ts @@ -0,0 +1,106 @@ +import { GitHub, Google } from "arctic"; +import { database } from "@/db/wdcStarter"; +import { + encodeBase32LowerCaseNoPadding, + encodeHexLowerCase, +} from "@oslojs/encoding"; +import { Session, sessions, User, users } from "@/db/wdcStarter/schema"; +import { env } from "@/env"; +import { eq } from "drizzle-orm/expressions"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { UserId } from "@src/use-cases/types"; +import { getSessionToken } from "@lib/wdcStarter/session"; + +const SESSION_REFRESH_INTERVAL_MS = 1000 * 60 * 60 * 24 * 15; +const SESSION_MAX_DURATION_MS = SESSION_REFRESH_INTERVAL_MS * 2; + +export const github = new GitHub( + env.GITHUB_CLIENT_ID, + env.GITHUB_CLIENT_SECRET, + `${env.HOST_NAME}/api/login/github/callback` +); + +export const googleAuth = new Google( + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + `${env.HOST_NAME}/api/login/google/callback` +); + +export function generateSessionToken(): string { + const bytes = new Uint8Array(20); + crypto.getRandomValues(bytes); + const token = encodeBase32LowerCaseNoPadding(bytes); + return token; +} + +export async function createSession( + token: string, + userId: UserId +): Promise { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session: Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + SESSION_MAX_DURATION_MS), + }; + await database.insert(sessions).values(session); + return session; +} + +export async function validateRequest(): Promise { + const sessionToken = await getSessionToken(); + if (!sessionToken) { + return { session: null, user: null }; + } + return validateSessionToken(sessionToken); +} + +export async function validateSessionToken( + token: string +): Promise { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const sessionInDb = await database.query.sessions.findFirst({ + where: eq(sessions.id, sessionId), + }); + if (!sessionInDb) { + return { session: null, user: null }; + } + if (Date.now() >= sessionInDb.expiresAt.getTime()) { + await database.delete(sessions).where(eq(sessions.id, sessionInDb.id)); + return { session: null, user: null }; + } + const user = await database.query.users.findFirst({ + where: eq(users.id, sessionInDb.userId), + }); + + if (!user) { + await database.delete(sessions).where(eq(sessions.id, sessionInDb.id)); + return { session: null, user: null }; + } + + if ( + Date.now() >= + sessionInDb.expiresAt.getTime() - SESSION_REFRESH_INTERVAL_MS + ) { + sessionInDb.expiresAt = new Date(Date.now() + SESSION_MAX_DURATION_MS); + await database + .update(sessions) + .set({ + expiresAt: sessionInDb.expiresAt, + }) + .where(eq(sessions.id, sessionInDb.id)); + } + return { session: sessionInDb, user }; +} + +export async function invalidateSession(sessionId: string): Promise { + await database.delete(sessions).where(eq(sessions.id, sessionId)); +} + +export async function invalidateUserSessions(userId: UserId): Promise { + await database.delete(sessions).where(eq(users.id, userId)); +} + +export type SessionValidationResult = + | { session: Session; user: User } + | { session: null; user: null }; \ No newline at end of file diff --git a/src/lib/wdcStarter/session.ts b/src/lib/wdcStarter/session.ts new file mode 100644 index 0000000..9c2805a --- /dev/null +++ b/src/lib/wdcStarter/session.ts @@ -0,0 +1,58 @@ +import "server-only"; +import { AuthenticationError } from "@/app/(main)/util"; +import { createSession, generateSessionToken, validateRequest } from "@lib/wdcStarter/auth"; +import { cache } from "react"; +import { cookies } from "next/headers"; +import { UserId } from "@/use-cases/types"; + +const SESSION_COOKIE_NAME = "session"; + +export async function setSessionTokenCookie( + token: string, + expiresAt: Date +): Promise { + const allCookies = await cookies(); + allCookies.set(SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + expires: expiresAt, + path: "/", + }); +} + +export async function deleteSessionTokenCookie(): Promise { + const allCookies = await cookies(); + allCookies.set(SESSION_COOKIE_NAME, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 0, + path: "/", + }); +} + +export async function getSessionToken(): Promise { + const allCookies = await cookies(); + const sessionCookie = allCookies.get(SESSION_COOKIE_NAME)?.value; + return sessionCookie; +} + +export const getCurrentUser = cache(async () => { + const { user } = await validateRequest(); + return user ?? undefined; +}); + +export const assertAuthenticated = async () => { + const user = await getCurrentUser(); + if (!user) { + throw new AuthenticationError(); + } + return user; +}; + +export async function setSession(userId: UserId) { + const token = generateSessionToken(); + const session = await createSession(token, userId); + await setSessionTokenCookie(token, session.expiresAt); +} \ No newline at end of file diff --git a/src/use-cases/types.ts b/src/use-cases/types.ts new file mode 100644 index 0000000..5a86833 --- /dev/null +++ b/src/use-cases/types.ts @@ -0,0 +1,21 @@ +export type Plan = "free" | "basic" | "premium"; +export type Role = "owner" | "admin" | "member"; + +export type UserId = number; + +export type UserProfile = { + id: UserId; + name: string | null; + image: string | null; +}; + +export type UserSession = { + id: UserId; +}; + +export type MemberInfo = { + name: string | null; + userId: UserId; + image: string | null; + role: Role; +}; \ No newline at end of file