feat(api): add prisma as orm (#49413)

This commit is contained in:
Oliver Eyton-Williams
2023-03-14 18:29:55 +01:00
committed by GitHub
parent ca2086cacb
commit fa7955dc75
12 changed files with 670 additions and 400 deletions

18
api/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Connecting to local database
The api uses the ORM Prisma and it needs the MongoDB instance to be a replica set.
## Atlas
If you use MongoDB Atlas, the set is managed for you.
## Local
The simplest way to run a replica set locally is to use the docker-compose file
in /tools. First disable any running MongoDB instance on your machin, then run
the docker-compose file.
```bash
cd tools
docker compose up -d
```

View File

@@ -1,14 +0,0 @@
import fastifyPlugin from 'fastify-plugin';
import fastifyMongo from '@fastify/mongodb';
import { FastifyInstance } from 'fastify';
import { MONGOHQ_URL } from '../utils/env';
async function connect(fastify: FastifyInstance) {
fastify.log.info(`Connecting to Mongodb`);
await fastify.register(fastifyMongo, {
url: MONGOHQ_URL
});
}
export const dbConnector = fastifyPlugin(connect);

23
api/db/prisma.ts Normal file
View File

@@ -0,0 +1,23 @@
import fp from 'fastify-plugin';
import { FastifyPluginAsync } from 'fastify';
import { PrismaClient } from '@prisma/client';
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient;
}
}
const prismaPlugin: FastifyPluginAsync = fp(async (server, _options) => {
const prisma = new PrismaClient();
await prisma.$connect();
server.decorate('prisma', prisma);
server.addHook('onClose', async server => {
await server.prisma.$disconnect();
});
});
export default prismaPlugin;

View File

@@ -9,8 +9,8 @@ import jwtAuthz from './plugins/fastify-jwt-authz';
import sessionAuth from './plugins/session-auth';
import { testRoutes } from './routes/test';
import { auth0Routes } from './routes/auth0';
import { dbConnector } from './db';
import { testMiddleware } from './middleware';
import {
AUTH0_AUDIENCE,
AUTH0_DOMAIN,
@@ -20,6 +20,8 @@ import {
SESSION_SECRET
} from './utils/env';
import prismaPlugin from './db/prisma';
const fastify = Fastify({
logger: { level: NODE_ENV === 'development' ? 'debug' : 'fatal' }
});
@@ -55,7 +57,8 @@ const start = async () => {
void fastify.use('/test', testMiddleware);
void fastify.register(dbConnector);
void fastify.register(prismaPlugin);
void fastify.register(testRoutes);
void fastify.register(auth0Routes, { prefix: '/auth0' });

View File

@@ -6,9 +6,9 @@
"dependencies": {
"@fastify/cookie": "^8.3.0",
"@fastify/middie": "8.1",
"@fastify/mongodb": "6.2.0",
"@fastify/session": "^10.1.1",
"connect-mongo": "^4.6.0",
"@prisma/client": "4.10.1",
"connect-mongo": "4.6.0",
"fastify": "4.14.1",
"fastify-auth0-verify": "^1.0.0",
"fastify-plugin": "^4.3.0",
@@ -40,7 +40,12 @@
"build": "tsc",
"develop": "nodemon index.ts",
"start": "NODE_ENV=production node index.js",
"test": "node --test -r ts-node/register **/*.test.ts"
"test": "node --test -r ts-node/register **/*.test.ts",
"prisma": "MONGOHQ_URL=mongodb://localhost:27017/freecodecamp?directConnection=true prisma",
"postinstall": "prisma generate"
},
"version": "0.0.1"
"version": "0.0.1",
"devDependencies": {
"prisma": "4.10.1"
}
}

144
api/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,144 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("MONGOHQ_URL")
}
type DonationStartDate {
date DateTime @map("_date") @db.Date
when String @map("_when")
}
type UserBadges {
/// Could not determine type: the field only had null or empty values in the sample set.
coreTeam Json?
}
type UserCompletedChallenges {
completedDate Float
id String
}
type UserProfileUi {
isLocked Boolean
showAbout Boolean
showCerts Boolean
showDonation Boolean
showHeatMap Boolean
showLocation Boolean
showName Boolean
showPoints Boolean
showPortfolio Boolean
showTimeLine Boolean
}
model AccessToken {
id String @id @map("_id")
created DateTime @db.Date
/// Multiple data types found: Float: 70.3%, Int: 29.7% out of 118 sampled entries
ttl Json
userId String @db.ObjectId
}
model AuthToken {
id String @id @map("_id")
created DateTime @db.Date
ttl Int
userId String @db.ObjectId
}
model Donation {
id String @id @default(auto()) @map("_id") @db.ObjectId
amount Int
customerId String
duration String
email String
provider String
startDate DonationStartDate
subscriptionId String
userId String @db.ObjectId
}
model UserToken {
id String @id @map("_id")
created DateTime @db.Date
ttl Float
userId String @db.ObjectId
}
model WebhookToken {
id String @id @map("_id")
created DateTime @db.Date
ttl Float
userId String @db.ObjectId
}
model expressRateRecords {
id String @id @default(auto()) @map("_id") @db.ObjectId
/// Field referred in an index, but found no data to define the type.
expirationDate Json?
@@index([expirationDate], map: "expirationDate_1")
}
model sessions {
id String @id @map("_id")
expires DateTime @db.Date
session String
@@index([expires], map: "expires_1")
}
model user {
id String @id @default(auto()) @map("_id") @db.ObjectId
about String
acceptedPrivacyTerms Boolean
badges UserBadges
completedChallenges UserCompletedChallenges[]
currentChallengeId String
email String
/// Could not determine type: the field only had null or empty values in the sample set.
emailAuthLinkTTL Json?
emailVerified Boolean
/// Could not determine type: the field only had null or empty values in the sample set.
emailVerifyTTL Json?
is2018DataVisCert Boolean
is2018FullStackCert Boolean
isApisMicroservicesCert Boolean
isBackEndCert Boolean
isBanned Boolean
isCheater Boolean
isDataAnalysisPyCertV7 Boolean
isDataVisCert Boolean
isDonating Boolean
isFrontEndCert Boolean
isFrontEndLibsCert Boolean
isFullStackCert Boolean
isHonest Boolean
isInfosecCertV7 Boolean
isInfosecQaCert Boolean
isJsAlgoDataStructCert Boolean
isMachineLearningPyCertV7 Boolean
isQaCertV7 Boolean
isRelationalDatabaseCertV8 Boolean
isRespWebDesignCert Boolean
isSciCompPyCertV7 Boolean
keyboardShortcuts Boolean?
location String
name String
picture String
/// Could not determine type: the field only had null or empty values in the sample set.
portfolio Json?
profileUI UserProfileUi
progressTimestamps Float[]
rand Float
sendQuincyEmail Boolean
theme String
username String
usernameDisplay String?
/// Could not determine type: the field only had null or empty values in the sample set.
yearsTopContributor Json?
}

View File

@@ -10,9 +10,63 @@ declare module 'fastify' {
}
}
// TODO: this probably belongs in a separate file and may not be 100% correct.
// All it's doing is providing the properties required by the current schema.
const defaultUser = {
about: '',
acceptedPrivacyTerms: false,
badges: {},
completedChallenges: [],
currentChallengeId: '',
emailVerified: false,
is2018DataVisCert: false,
is2018FullStackCert: false,
isApisMicroservicesCert: false,
isBackEndCert: false,
isBanned: false,
isCheater: false,
isDataAnalysisPyCertV7: false,
isDataVisCert: false,
isDonating: false,
isFrontEndCert: false,
isFrontEndLibsCert: false,
isFullStackCert: false,
isHonest: false,
isInfosecCertV7: false,
isInfosecQaCert: false,
isJsAlgoDataStructCert: false,
isMachineLearningPyCertV7: false,
isQaCertV7: false,
isRelationalDatabaseCertV8: false,
isRespWebDesignCert: false,
isSciCompPyCertV7: false,
keyboardShortcuts: false,
location: '',
name: '',
picture: '',
profileUI: {
isLocked: false,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
},
progressTimestamps: [],
// TODO: check what this is used for in api-server and if we need it
rand: 0,
sendQuincyEmail: false,
theme: 'default',
// TODO: generate a UUID like in api-server
username: ''
};
export const auth0Routes: FastifyPluginCallback = (fastify, _options, done) => {
fastify.addHook('onRequest', fastify.authenticate);
const collection = fastify.mongo.db?.collection('user');
fastify.get('/callback', async (req, _res) => {
const auth0Res = await fetch(
@@ -31,12 +85,17 @@ export const auth0Routes: FastifyPluginCallback = (fastify, _options, done) => {
}
const { email } = (await auth0Res.json()) as { email: string };
const user = await collection?.findOne({ email });
if (user) {
req.session.user = { id: user._id.toString() };
const existingUser = await fastify.prisma.user.findFirst({
where: { email }
});
if (existingUser) {
req.session.user = { id: existingUser.id };
} else {
const DBRes = await collection?.insertOne({ email });
req.session.user = { id: DBRes?.insertedId.toString() ?? '' };
const newUser = await fastify.prisma.user.create({
data: { ...defaultUser, email }
});
req.session.user = { id: newUser.id };
}
await req.session.save();
});

View File

@@ -1,20 +1,8 @@
import { ObjectId } from '@fastify/mongodb';
import { FastifyPluginCallback } from 'fastify';
export const testRoutes: FastifyPluginCallback = (fastify, _options, done) => {
const collection = fastify.mongo.db?.collection('user');
fastify.addHook('onRequest', fastify.authenticateSession);
fastify.get('/test', async (request, _reply) => {
if (!collection) {
return { error: 'No collection' };
}
const userId = new ObjectId(request.session.user.id);
const user = await collection?.findOne({ _id: userId });
return { user };
});
fastify.put<{ Body: { quincyEmails: boolean } }>(
'/update-privacy-terms',
{
@@ -28,27 +16,21 @@ export const testRoutes: FastifyPluginCallback = (fastify, _options, done) => {
}
}
},
(req, res) => {
async req => {
const {
body: { quincyEmails }
} = req;
const update = {
acceptedPrivacyTerms: true,
sendQuincyEmail: !!quincyEmails
};
const userId = new ObjectId(req.session.user.id);
return collection
?.updateOne({ _id: userId }, { $set: update })
.then(() => {
void res.code(200).send({ msg: 'Successfully updated' });
})
.catch(err => {
fastify.log.error(err);
void res.code(500).send({ msg: 'Something went wrong' });
try {
await fastify.prisma.user.update({
where: { id: req.session.user.id },
data: { acceptedPrivacyTerms: true, sendQuincyEmail: quincyEmails }
});
return { msg: 'Successfully updated' };
} catch (err) {
fastify.log.error(err);
throw { msg: 'Something went wrong' };
}
}
);
done();

View File

@@ -0,0 +1,25 @@
version: '3.8'
services:
db:
image: mongo
container_name: mongodb
command: mongod --replSet rs0
restart: unless-stopped
ports:
- 27018:27017
volumes:
- db-data:/data
setup:
image: mongo
depends_on:
- db
restart: on-failure
entrypoint: [
'bash',
'-c',
# This will try to initiate the replica set, until it succeeds twice (i.e. until the replica set is already initialized)
'mongosh --host db:27017 --eval ''try {rs.initiate();} catch (err) { if(err.codeName !== "AlreadyInitialized") throw err };'''
]
volumes:
db-data:

View File

@@ -29,7 +29,8 @@ if (process.env.NODE_ENV !== 'development') {
}
export const MONGOHQ_URL =
process.env.MONGOHQ_URL || 'mongodb://localhost:27017/freecodecamp';
process.env.MONGOHQ_URL ??
'mongodb://localhost:27017/freecodecamp?directConnection=true';
export const NODE_ENV = process.env.NODE_ENV;
export const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN;
export const AUTH0_AUDIENCE = process.env.AUTH0_AUDIENCE;

712
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
# Database
MONGOHQ_URL=mongodb://127.0.0.1:27017/freecodecamp
MONGOHQ_URL=mongodb://127.0.0.1:27017/freecodecamp?directConnection=true
# Logging
SENTRY_DSN=dsn_from_sentry_dashboard