Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate to Postgres #497

Merged
merged 59 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
25c1ec7
Start writing schema
js0mmer Oct 24, 2024
58812f3
some constraints and indexes
js0mmer Oct 24, 2024
a28b575
schema edits
js0mmer Oct 24, 2024
a20963d
coursebag schema
js0mmer Oct 25, 2024
f57a34e
rename coursebag table to saved_courses
js0mmer Oct 25, 2024
9b50970
review content can be null
js0mmer Oct 26, 2024
aa7b9e7
review updated at can be null
js0mmer Oct 26, 2024
d557161
start writing migration script
js0mmer Oct 26, 2024
bf02e32
more work on migration script
js0mmer Oct 26, 2024
260a9aa
roadmap migration script
js0mmer Oct 26, 2024
d83079d
add email/picture to user schema
js0mmer Oct 26, 2024
f3354e8
migration script almost done
js0mmer Oct 26, 2024
be35343
normalize planner quarter names
js0mmer Oct 27, 2024
ef95b80
Planner years type
js0mmer Oct 27, 2024
c2056d5
cascade review deletes
js0mmer Oct 29, 2024
b0c62d0
Merge branch 'master' into drizzle
js0mmer Oct 30, 2024
bb981e8
singular table names
js0mmer Oct 31, 2024
0f10cee
migrate reviews/reports routes to postgres + connect-pg sessions
js0mmer Nov 1, 2024
22e4c22
fix mapping with number, scores, and featured
js0mmer Nov 1, 2024
c6aa1c0
roadmap + coursebag route migration
js0mmer Nov 1, 2024
99291c1
remove connect-mongodb-session
js0mmer Nov 1, 2024
39c3ee8
small refactors to reviews
js0mmer Nov 1, 2024
d807366
migrate sessions
js0mmer Nov 1, 2024
b8064d5
fix roadmap timestamp migration
js0mmer Nov 1, 2024
cc1f755
fix session userId transfer
js0mmer Nov 1, 2024
56d47aa
do not rely on old userid to determine if review author
js0mmer Nov 1, 2024
583c338
refactor login check, cookie usage
js0mmer Nov 1, 2024
52dfec6
add database url to env
js0mmer Nov 1, 2024
5eb06c7
lint error
js0mmer Nov 1, 2024
c009729
remove comment
js0mmer Nov 1, 2024
3db342c
fix the error handler?
js0mmer Nov 1, 2024
d9b0c6a
remove some mongoose stuff
js0mmer Nov 1, 2024
682b603
adjust review vote updating
js0mmer Nov 1, 2024
781d0e3
fix roadmap deletes and duplication
js0mmer Nov 1, 2024
7602644
fix unsaved change detection with planner ids
js0mmer Nov 2, 2024
ddaead4
fix unverified review query
js0mmer Nov 2, 2024
6d0b75b
simplify reports route
js0mmer Nov 2, 2024
95503a7
add some indexes
js0mmer Nov 2, 2024
a7af1a1
refactor vote check constraint
js0mmer Nov 3, 2024
73b483a
dates to strings helper
js0mmer Nov 3, 2024
b7a4e63
eslint is a little dumb
js0mmer Nov 3, 2024
f47c975
planner -> plannerData
js0mmer Nov 3, 2024
ab0cbae
featuredReviewCriteria variable
js0mmer Nov 3, 2024
f69335a
clean up reviews where clause
js0mmer Nov 3, 2024
af2938b
Adjust roadmap save execution
js0mmer Nov 4, 2024
6f3e948
improve roadmap save route readability
js0mmer Nov 4, 2024
bb726db
typesafe dateToStrings helper
js0mmer Nov 4, 2024
3fc2e5e
quarterNames export
js0mmer Nov 4, 2024
a509685
Merge branch 'master' into drizzle
js0mmer Nov 4, 2024
15237ac
refactor conditional filters in review query
js0mmer Nov 4, 2024
1c146d3
explicitly define column names, use underscores
js0mmer Nov 5, 2024
b5f2ac0
update example env
js0mmer Nov 5, 2024
4cde92d
adjust coursebag slice, use hook (redux reducer should not make api c…
js0mmer Nov 5, 2024
bf83baf
Merge branch 'drizzle' of github.com:icssc/peterportal-client into dr…
js0mmer Nov 6, 2024
77d1015
update readme
js0mmer Nov 6, 2024
9482985
fix verified condition
js0mmer Nov 6, 2024
9d65326
replace api calls on verify page with removing from array
js0mmer Nov 6, 2024
6775080
remove mongoose
js0mmer Nov 6, 2024
a1cb271
fix vote check
js0mmer Nov 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading