Skip to content

Commit

Permalink
Migrate to Postgres (#497)
Browse files Browse the repository at this point in the history
  • Loading branch information
js0mmer authored Nov 6, 2024
1 parent be6ddc4 commit 1b2b063
Show file tree
Hide file tree
Showing 65 changed files with 1,922 additions and 1,376 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
CI: false
PUBLIC_API_URL: ${{secrets.PUBLIC_API_URL}}
PUBLIC_API_GRAPHQL_URL: ${{secrets.PUBLIC_API_GRAPHQL_URL}}
MONGO_URL: ${{secrets.MONGO_URL}}
DATABASE_URL: ${{ github.event_name == 'pull_request' && secrets.DEV_DATABASE_URL || secrets.PROD_DATABASE_URL }}
SESSION_SECRET: ${{secrets.SESSION_SECRET}}
GOOGLE_CLIENT: ${{secrets.GOOGLE_CLIENT}}
GOOGLE_SECRET: ${{secrets.GOOGLE_SECRET}}
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ Features include:
- [PeterPortal API](https://github.com/icssc/peterportal-api-next)
- Express
- React
- tRPC
- SST and AWS CDK
- MongoDB
- PostgreSQL
- Drizzle ORM
- GraphQL
- TypeScript
- Vite
Expand Down Expand Up @@ -77,7 +79,7 @@ git clone https://github.com/<your username>/peterportal-client

5. Rename the `.env.example` file in the api directory to `.env`. This includes the minimum environment variables needed for running the backend.

6. (Optional) Set up your own MongoDB and Google OAuth to be able to test features that require signing in such as leaving reviews or saving roadmaps to your account. Add additional variables/secrets to the .env file from the previous step.
6. (Optional) Set up your own PostgreSQL database and Google OAuth to be able to test features that require signing in such as leaving reviews or saving roadmaps to your account. Add additional variables/secrets to the .env file from the previous step.

## Open Source Contribution Guide

Expand Down
4 changes: 2 additions & 2 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ PUBLIC_API_URL=https://api-next.peterportal.org/v1/rest/
PUBLIC_API_GRAPHQL_URL=https://api-next.peterportal.org/v1/graphql
PORT=8080 # should match the port on the frontend proxy under site/vite.config.ts

# below are stubs of variables/secrets for MongoDB, google oauth, and recaptcha
# below are stubs of variables/secrets for the PostgreSQL database, google oauth, and recaptcha
# these are necessary for features that require logging in
# MONGO_URL=<secret>
# DATABASE_URL=<secret>
# SESSION_SECRET=<secret>
# GOOGLE_CLIENT=<client>
# GOOGLE_SECRET=<secret>
Expand Down
11 changes: 11 additions & 0 deletions api/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
9 changes: 6 additions & 3 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,32 @@
"@trpc/server": "^10.45.1",
"@vendia/serverless-express": "^4.12.6",
"axios": "^1.6.8",
"connect-mongodb-session": "^5.0.0",
"connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.6",
"dotenv-flow": "^4.1.0",
"drizzle-orm": "^0.35.3",
"express": "^4.19.2",
"express-session": "^1.18.0",
"mongoose": "^8.3.3",
"morgan": "^1.10.0",
"passport": "^0.7.0",
"passport-google-oauth": "^2.0.0",
"pg": "^8.13.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@peterportal/types": "workspace:*",
"@types/connect-mongodb-session": "^2.4.7",
"@types/connect-pg-simple": "^7.0.3",
"@types/cookie-parser": "^1.4.7",
"@types/dotenv-flow": "^3.3.3",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.0",
"@types/morgan": "^1.9.9",
"@types/passport": "^1.0.16",
"@types/passport-google-oauth": "^1.0.45",
"@types/pg": "^8.11.10",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0",
"drizzle-kit": "^0.26.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"nodemon": "^3.1.7",
Expand Down
73 changes: 16 additions & 57 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,18 @@
* @module
*/

import express from 'express';
import express, { ErrorRequestHandler } from 'express';
import logger from 'morgan';
import cookieParser from 'cookie-parser';
import passport from 'passport';
import session from 'express-session';
import MongoDBStore from 'connect-mongodb-session';
import connectPgSimple from 'connect-pg-simple';
import dotenv from 'dotenv-flow';
import serverlessExpress from '@vendia/serverless-express';
import * as trpcExpress from '@trpc/server/adapters/express';
import mongoose, { Mongoose } from 'mongoose';
// load env
dotenv.config();

// Configs
import { DB_NAME, COLLECTION_NAMES } from './helpers/mongo';

// Custom Routes
import authRouter from './controllers/auth';

Expand All @@ -30,26 +26,11 @@ import passportInit from './config/passport';
// instantiate app
const app = express();

// Setup mongo store for sessions
const mongoStore = MongoDBStore(session);
const PGStore = connectPgSimple(session);

let store: undefined | MongoDBStore.MongoDBStore;
if (process.env.MONGO_URL) {
store = new mongoStore({
uri: process.env.MONGO_URL,
databaseName: DB_NAME,
collection: COLLECTION_NAMES.SESSIONS,
});
} else {
console.log('MONGO_URL env var is not defined!');
if (!process.env.DATABASE_URL) {
console.log('DATABASE_URL env var is not defined!');
}
// Catch errors
mongoose.connection.on('error', function (error) {
console.log(error);
});
store?.on('error', function (error) {
console.log(error);
});
// Setup Passport and Sessions
if (!process.env.SESSION_SECRET) {
console.log('SESSION_SECRET env var is not defined!');
Expand All @@ -60,7 +41,10 @@ app.use(
resave: false,
saveUninitialized: false,
cookie: { maxAge: SESSION_LENGTH },
store: store,
store: new PGStore({
conString: process.env.DATABASE_URL,
createTableIfMissing: true,
}),
}),
);

Expand Down Expand Up @@ -113,45 +97,20 @@ app.use('/api', expressRouter);
/**
* Error Handler
*/
app.use(function (req, res) {
console.error(req);
res.status(500).json({ error: `Internal Serverless Error - '${req}'` });
});

export const connect = async () => {
let conn: null | Mongoose = null;
const uri = process.env.MONGO_URL;

if (conn == null && uri) {
conn = await mongoose.connect(uri!, {
dbName: DB_NAME,
serverSelectionTimeoutMS: 5000,
});
}
return conn;
const errorHandler: ErrorRequestHandler = (err, req, res) => {
console.error(err);
res.status(500).json({ message: 'Internal Serverless Error', err });
};
app.use(errorHandler);

let serverlessExpressInstance: ReturnType<typeof serverlessExpress>;
async function setup(event: unknown, context: unknown) {
await connect();
serverlessExpressInstance = serverlessExpress({ app });
return serverlessExpressInstance(event, context);
}
// run local dev server
const NODE_ENV = process.env.NODE_ENV ?? 'development';
if (NODE_ENV === 'development') {
const port = process.env.PORT ?? 8080;
connect().then(() => {
app.listen(port, () => {
console.log('Listening on port', port);
});
app.listen(port, () => {
console.log('Listening on port', port);
});
}

export const handler = async (event: unknown, context: unknown) => {
if (serverlessExpressInstance) {
return serverlessExpressInstance(event, context);
}
return setup(event, context);
};
// export for serverless
export const handler = serverlessExpress({ app });
4 changes: 2 additions & 2 deletions api/src/config/passport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
@module PassportConfig
*/

import { User } from '@peterportal/types';
import { PassportUser } from '@peterportal/types';
import passport from 'passport';
import { OAuth2Strategy as GoogleStrategy } from 'passport-google-oauth';

Expand All @@ -11,7 +11,7 @@ export default function passportInit() {
done(null, user);
});

passport.deserializeUser(function (user: false | User | null | undefined, done) {
passport.deserializeUser(function (user: false | PassportUser | null | undefined, done) {
done(null, user);
});

Expand Down
26 changes: 20 additions & 6 deletions api/src/controllers/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import express, { Request, Response } from 'express';
import passport from 'passport';
import { SESSION_LENGTH } from '../config/constants';
import { User } from '@peterportal/types';
import { PassportUser } from '@peterportal/types';
import { db } from '../db';
import { user } from '../db/schema';

const router = express.Router();

Expand All @@ -10,11 +12,23 @@ const router = express.Router();
* @param req Express Request Object
* @param res Express Response Object
*/
function successLogin(req: Request, res: Response) {
// set the user cookie
res.cookie('user', req.user, {
async function successLogin(req: Request, res: Response) {
const {
email,
name,
id: googleId,
picture,
} = req.user as { email: string; id: string; name: string; picture: string };
// upsert user data in db
const userData = await db
.insert(user)
.values({ googleId, name, email, picture })
.onConflictDoUpdate({ target: user.googleId, set: { name, email, picture } })
.returning();
res.cookie('user', true, {
maxAge: SESSION_LENGTH,
});
req.session.userId = userData[0].id;
// redirect browser to the page they came from
const returnTo = req.session.returnTo ?? '/';
delete req.session.returnTo;
Expand Down Expand Up @@ -51,7 +65,7 @@ router.get('/google/callback', function (req, res) {
'google',
{ failureRedirect: '/', session: true },
// provides user information to determine whether or not to authenticate
function (err: Error, user: User | false | null) {
function (err: Error, user: PassportUser | false | null) {
if (err) return console.error(err);
if (!user) return console.error('Invalid login data');
// manually login
Expand All @@ -60,7 +74,7 @@ router.get('/google/callback', function (req, res) {
// check if user is an admin
const allowedUsers = JSON.parse(process.env.ADMIN_EMAILS ?? '[]');
if (allowedUsers.includes(user.email)) {
req.session.passport!.isAdmin = true;
req.session.isAdmin = true;
}
req.session.returnTo = returnTo;
successLogin(req, res);
Expand Down
2 changes: 2 additions & 0 deletions api/src/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import professorsRouter from './professors';
import reportsRouter from './reports';
import reviewsRouter from './reviews';
import roadmapsRouter from './roadmap';
import { savedCoursesRouter } from './savedCourses';
import scheduleRouter from './schedule';
import usersRouter from './users';

Expand All @@ -13,6 +14,7 @@ export const appRouter = router({
roadmaps: roadmapsRouter,
reports: reportsRouter,
reviews: reviewsRouter,
savedCourses: savedCoursesRouter,
schedule: scheduleRouter,
users: usersRouter,
});
Expand Down
32 changes: 11 additions & 21 deletions api/src/controllers/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,35 @@
@module ReportsRoute
*/

import Report from '../models/report';
import { adminProcedure, publicProcedure, router } from '../helpers/trpc';
import { z } from 'zod';
import { ReportData, reportSubmission } from '@peterportal/types';
import { db } from '../db';
import { report } from '../db/schema';
import { eq } from 'drizzle-orm';
import { datesToStrings } from '../helpers/date';

const reportsRouter = router({
/**
* Get all reports
*/
get: adminProcedure.query(async () => {
const reports = await Report.find<ReportData>();
return reports;
return (await db.select().from(report)).map((report) => datesToStrings(report)) as ReportData[];
}),
/**
* Add a report
*/
add: publicProcedure.input(reportSubmission).mutation(async ({ input }) => {
const report = new Report(input);
await report.save();

await db.insert(report).values(input);
return input;
}),
/**
* Delete a report
* Delete reports by review id
*/
delete: adminProcedure
.input(z.object({ id: z.string().optional(), reviewID: z.string().optional() }))
.mutation(async ({ input }) => {
if (input.id) {
// delete report by report id
return await Report.deleteOne({ _id: input.id });
} else if (input.reviewID) {
// delete report(s) by review id
return await Report.deleteMany({ reviewID: input.reviewID });
} else {
// no id or reviewID specified
return false;
}
}),
delete: adminProcedure.input(z.object({ reviewId: z.number() })).mutation(async ({ input }) => {
await db.delete(report).where(eq(report.reviewId, input.reviewId));
return true;
}),
});

export default reportsRouter;
Loading

0 comments on commit 1b2b063

Please sign in to comment.