This commit is contained in:
2026-06-24 14:20:05 +02:00
commit 1c859d20c8
442 changed files with 25625 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export * from './prisma'
export * from './redis'
+4
View File
@@ -0,0 +1,4 @@
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient({
log: ['query', 'info', 'warn', 'error']
})
+5
View File
@@ -0,0 +1,5 @@
import { createClient } from 'redis'
export const redis = createClient({
url: process.env.REDIS_URL
})
+2
View File
@@ -0,0 +1,2 @@
export const __prod__ = process.env.NODE_ENV ==='production'
export const SESSION_COOKIE_NAME = 'qid'
+113
View File
@@ -0,0 +1,113 @@
import { prisma, redis } from '@/src/configs'
import { __prod__, SESSION_COOKIE_NAME } from '@/src/constants'
import { createUserLoader } from '@/src/loaders'
import { PostResolver, UserResolver } from '@/src/resolvers'
import { Context } from '@/src/types'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'
import RedisStore from 'connect-redis'
import cors from 'cors'
import express from 'express'
import session from 'express-session'
import http from 'http'
import { buildSchema } from 'type-graphql'
const main = async () => {
const app = express()
// app.options('*', cors({
// origin: process.env.FRONTEND_ORIGIN,
// credentials: true,
// methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
// allowedHeaders: ['Content-Type', 'Authorization']
// }))
// app.use(cors({
// origin: process.env.FRONTEND_ORIGIN,
// credentials: true,
// methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
// allowedHeaders: ['Content-Type', 'Authorization']
// }))
// Required for cookies to work
app.set('trust proxy', true)
await redis.connect()
// It is important that the session middleware runs before expressMiddleware(apolloServer, { ... })
// as we'll be using session inside Apollo's resolvers.
app.use(
session({
name: SESSION_COOKIE_NAME, // This is the name of the cookie that will be stored on the client (usually a browser) when a new session is created, i.e., when the user logs in.
store: new RedisStore({ // This is the key-value database where active user sessions will be stored.
client: redis,
disableTouch: true // This ensures that the session cookies have no TTL, i.e., they will not automatically expire.
}),
cookie: {
httpOnly: false, // Prevents the cookie from being accessed by client-side scripts.
sameSite: 'strict', // Ensures that the cookie is only sent to the server if the browser is at the exact same FQDN as that of the server. In other words, requests made to the server when the browser is at a third-party website will not include the cookie.
secure: __prod__ // If set to true, the cookie can only be set if the server is being accessed via HTTPS. The cookie will also only be sent to HTTPS endpoints.
},
secret: process.env.SESSION_SECRET,
saveUninitialized: false, // Officially recommended setting
resave: false // Officially recommended setting
})
)
/*
1. Create an Apollo server
2. Start that Apollo server
3. Expose that Apollo server to the /graphql endpoint with the following middleware:
- cors(): CORS settings for the /graphql route
- express.json(): Ensures that Express intercepts all requests and only passes the ones containing valid JSON to Apollo.
- expressMiddleware(): Extends the context object which every resolver has access to.
The context object includes the req and res objects by default, and we are extending it to include the prisma and redis clients as well.
*/
const httpServer = http.createServer(app)
const apolloServer = new ApolloServer<Context>({
introspection: true,
schema: await buildSchema({
resolvers: [
PostResolver,
UserResolver
]
}),
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], // Officially recommended plugin to ensure that once the server shuts down, it does so correctly
})
await apolloServer.start()
app.use(
'/graphql',
cors({
origin: process.env.FRONTEND_ORIGIN,
credentials: true, // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}),
express.json(),
expressMiddleware(apolloServer, {
context: async ({ req, res }): Promise<Context> => ({
req,
res,
prisma,
redis,
userLoader: createUserLoader()
})
})
)
app.listen(parseInt(process.env.API_PORT), () => {
console.log(`Server started on localhost:${process.env.API_PORT}.`)
})
app.get('/', (_, res) => {
res.send('Start querying at /graphql.')
})
}
main().catch(e => { console.error(e) })
+13
View File
@@ -0,0 +1,13 @@
import { User } from '@/prisma/generated/type-graphql'
import DataLoader from 'dataloader'
import { prisma } from '../configs'
// https://youtu.be/I6ypD7qv3Z8?si=Pm9PfzpDfRVV-KY3&t=40600
export const createUserLoader = () => new DataLoader<string, User>(async (userIDs) => {
const users = await prisma.user.findMany({ where: { id: { in: userIDs as string[] } } })
const map: Record<string, User> = {}
users.forEach(u => {
map[u.id] = u
})
return userIDs.map(id => map[id])
})
+1
View File
@@ -0,0 +1 @@
export * from './createUserLoader'
+1
View File
@@ -0,0 +1 @@
export * from './isAuth'
+10
View File
@@ -0,0 +1,10 @@
import { MiddlewareFn } from 'type-graphql'
import { Context } from '@/src/types'
// https://typegraphql.com/docs/middlewares.html
export const isAuth: MiddlewareFn<Context> = async ({ context: { req }}, next) => {
if (!req.session?.userID) {
throw new Error('Unauthenticated!')
}
return await next()
}
+2
View File
@@ -0,0 +1,2 @@
export * from './post'
export * from './user'
+273
View File
@@ -0,0 +1,273 @@
import { Post, User } from '@/prisma/generated/type-graphql'
import { isAuth } from '@/src/middlewares'
import { Context, PostInput, PostResponse } from '@/src/types'
import { Arg, Ctx, FieldResolver, Int, Mutation, Query, Resolver, Root, UseMiddleware, GraphQLTimestamp } from 'type-graphql'
@Resolver(Post)
export class PostResolver {
@FieldResolver(() => User)
async author(
@Root() post: Post,
@Ctx() { prisma, userLoader }: Context
): Promise<User> {
return (await prisma.user.findUnique({ where: { id: post.authorID } }))!
// Using DataLoader is no longer necessary as Prisma already supports automatically batching requests together
// https://www.prisma.io/docs/orm/prisma-client/queries/query-optimization-performance
// return await userLoader.load(post.authorID)
}
@FieldResolver(() => String)
snippet(
@Root() post: Post
): string {
return post.content.split(' ').slice(0, 25).join(' ') + '...'
}
@FieldResolver(() => Boolean, { nullable: true })
async upvoted(
@Root() post: Post,
@Ctx() { req, prisma }: Context
): Promise<boolean | null> {
if (!req.session?.userID) {
return null
}
return !!(await prisma.upvote.findUnique({ where: { userID_postID: { postID: post.id, userID: req.session.userID } } }))
}
@FieldResolver(() => Boolean, { nullable: true })
async downvoted(
@Root() post: Post,
@Ctx() { req, prisma }: Context
): Promise<boolean | null> {
if (!req.session?.userID) {
return null
}
return !!(await prisma.downvote.findUnique({ where: { userID_postID: { postID: post.id, userID: req.session.userID } } }))
}
@Query(() => Post, { nullable: true }) // nullable means this query can return null
async post(
@Ctx() { prisma }: Context,
@Arg('id', () => String) id: string,
): Promise<Post | null> {
return await prisma.post.findUnique({ where: { id } })
}
@Query(() => [Post])
async posts(
@Ctx() { req, prisma }: Context,
@Arg('limit', () => Int, { nullable: true }) limit: number = 5,
@Arg('cursor', () => GraphQLTimestamp, { nullable: true }) cursor?: Date
): Promise<Post[]> {
// https://www.prisma.io/docs/orm/prisma-client/queries/pagination#cursor-based-pagination
if (!cursor) {
return await prisma.post.findMany({
take: limit,
where: req.session.userID ? {
authorID: req.session.userID
} : undefined,
orderBy: {
createdAt: 'desc'
}
})
}
return await prisma.post.findMany({
take: limit,
skip: 1,
cursor: {
createdAt: cursor
},
where: req.session.userID ? {
authorID: req.session.userID
} : undefined,
orderBy: {
createdAt: 'desc'
}
})
}
@UseMiddleware(isAuth)
@Mutation(() => PostResponse)
async createPost(
@Ctx() { req, prisma }: Context,
@Arg('input', () => PostInput) { title, content }: PostInput,
): Promise<PostResponse> {
if (!content) {
return {
errors: [{
field: 'content',
message: 'Content cannot be empty!'
}]
}
}
return {
post: await prisma.post.create({ data: { title, content, authorID: req.session.userID! } })
}
}
@UseMiddleware(isAuth)
@Mutation(() => PostResponse)
async updatePost(
@Ctx() { req, prisma }: Context,
@Arg('id', () => String) id: string,
@Arg('title', () => String, { nullable: true }) title?: string,
@Arg('content', () => String, { nullable: true }) content?: string,
): Promise<PostResponse> {
const post = await prisma.post.findUnique({ where: { id } })
if (!post) {
return {
errors: [{
field: 'id',
message: 'Post doesn\'t exist!'
}]
}
}
if (post.authorID != req.session.userID!) {
throw new Error('Unauthorised!')
}
if (!content) {
return {
errors: [{
field: 'content',
message: 'Content cannot be empty!'
}]
}
}
return { post: await prisma.post.update({ where: { id }, data: { title: title || '', content } }) }
}
@UseMiddleware(isAuth)
@Mutation(() => Boolean)
async deletePost(
@Ctx() { req, prisma }: Context,
@Arg('id', () => String) id: string
): Promise<Boolean> {
try {
const post = await prisma.post.findUnique({ where: { id } })
if (!post) {
throw new Error('Post not found!')
}
if (req.session.userID != post.authorID) {
throw new Error('Unauthorised!')
}
const transactions: any[] = []
if (await prisma.upvote.findUnique({ where: { userID_postID: { userID: req.session.userID, postID: id } } })) {
transactions.push(prisma.upvote.delete({ where: { userID_postID: { userID: req.session.userID, postID: id } } }))
}
if (await prisma.downvote.findUnique({ where: { userID_postID: { userID: req.session.userID, postID: id } } })) {
transactions.push(prisma.downvote.delete({ where: { userID_postID: { userID: req.session.userID, postID: id } } }))
}
transactions.push(prisma.post.delete({ where: { id } }))
await prisma.$transaction(transactions)
return true
}
catch {
return false
}
}
@UseMiddleware(isAuth)
@Mutation(() => Post, { nullable: true })
async upvote(
@Arg('postID', () => String) postID: string,
@Ctx() { req, prisma }: Context
): Promise<Post | null> {
const post = await prisma.post.findUnique({ where: { id: postID } })
if (!post) {
return null
}
const existingUpvote = await prisma.upvote.findUnique({ where: { userID_postID: { postID, userID: req.session.userID! } } })
if (existingUpvote) {
return post
}
// https://www.prisma.io/docs/orm/prisma-client/queries/transactions#interactive-transactions
return await prisma.$transaction(async tx => {
await tx.upvote.create({ data: { postID, userID: req.session.userID! } })
let currentPoints = post.points + 1
const existingDownvote = await tx.downvote.findUnique({ where: { userID_postID: { postID, userID: req.session.userID! } } })
if (existingDownvote) {
currentPoints += 1
await tx.downvote.delete({ where: { userID_postID: { postID, userID: req.session.userID! } } })
}
return await tx.post.update({ data: { points: currentPoints }, where: { id: postID } })
})
}
@UseMiddleware(isAuth)
@Mutation(() => Post, { nullable: true })
async downvote(
@Arg('postID', () => String) postID: string,
@Ctx() { req, prisma }: Context
): Promise<Post | null> {
const post = await prisma.post.findUnique({ where: { id: postID } })
if (!post) {
return null
}
const existingDownvote = await prisma.downvote.findUnique({ where: { userID_postID: { postID, userID: req.session.userID! } } })
if (existingDownvote) {
return post
}
return await prisma.$transaction(async tx => {
await prisma.downvote.create({ data: { postID, userID: req.session.userID! } })
let currentPoints = post.points - 1
const existingUpvote = await prisma.upvote.findUnique({ where: { userID_postID: { postID, userID: req.session.userID! } } })
if (existingUpvote) {
currentPoints -= 1
await prisma.upvote.delete({ where: { userID_postID: { postID, userID: req.session.userID! } } })
}
return await prisma.post.update({ data: { points: currentPoints }, where: { id: postID } })
})
}
@UseMiddleware(isAuth)
@Mutation(() => Post, { nullable: true })
async removeUpvote(
@Arg('postID', () => String) postID: string,
@Ctx() { req, prisma }: Context
): Promise<Post | null> {
const post = await prisma.post.findUnique({ where: { id: postID } })
if (!post) {
return null
}
const existingUpvote = await prisma.upvote.findUnique({ where: { userID_postID: { postID, userID: req.session.userID! } } })
// https://www.prisma.io/docs/orm/prisma-client/queries/transactions#sequential-prisma-client-operations
if (existingUpvote) {
const [_firstOperationResult, secondOperationResult] = await prisma.$transaction([
prisma.upvote.delete({ where: { userID_postID: { postID, userID: req.session.userID! } } }),
prisma.post.update({ where: { id: postID }, data: { points: post.points - 1 } })
])
return secondOperationResult
}
return post
}
@UseMiddleware(isAuth)
@Mutation(() => Post, { nullable: true })
async removeDownvote(
@Arg('postID', () => String) postID: string,
@Ctx() { req, prisma }: Context
): Promise<Post | null> {
const post = await prisma.post.findUnique({ where: { id: postID } })
if (!post) {
return null
}
const existingDownvote = await prisma.downvote.findUnique({ where: { userID_postID: { postID, userID: req.session.userID! } } })
if (existingDownvote) {
const [_firstOperationResult, secondOperationResult] = await prisma.$transaction([
prisma.downvote.delete({ where: { userID_postID: { postID, userID: req.session.userID! } } }),
prisma.post.update({ where: { id: postID }, data: { points: post.points + 1 } })
])
return secondOperationResult
}
return post
}
}
+238
View File
@@ -0,0 +1,238 @@
import { User } from '@/prisma/generated/type-graphql'
import { SESSION_COOKIE_NAME } from '@/src/constants'
import { Context, ResetPasswordResponse, UsernamePasswordInput, UserResponse } from '@/src/types'
import { sendEmail, validateEmail } from '@/src/utils'
import argon2 from 'argon2'
import { Arg, Ctx, FieldResolver, Mutation, Query, Resolver, Root } from 'type-graphql'
import { v7 as uuid_v7 } from 'uuid'
@Resolver(User) // It it necessary to pass User into @Resolver() for @FieldResolver() and Root() to work. Whatever is passed into @Resolver() is gonna be a GraphQL object whose fields we can customise. Customising means using @FieldResolver() to determine what gets returned for those fields instead of the database values returned by Prisma.
export class UserResolver {
@FieldResolver(() => String)
async email(
@Root() user: User,
@Ctx() { req }: Context
): Promise<String> {
if (req.session.userID != user.id) {
return ''
}
return user.email || ''
}
@Mutation(() => UserResponse)
async register(
@Ctx() { req, prisma }: Context,
@Arg('input', () => UsernamePasswordInput) { email, username, password }: UsernamePasswordInput
): Promise<UserResponse> {
const errors = []
if (username.length < 8) {
errors.push({
field: 'username',
message: 'Length must be at least 8.'
})
}
else if (await prisma.user.findUnique({ where: { username } }) ? true : false) {
errors.push({
field: 'username',
message: 'Username already exists!'
})
}
if (email) {
if (!validateEmail(email)) {
errors.push({
field: 'email',
message: 'Invalid email!'
})
}
else if (await prisma.user.findUnique({ where: { email } }) ? true : false) {
errors.push({
field: 'email',
message: 'Email already in use!'
})
}
}
if (password.length < 8) {
errors.push({
field: 'password',
message: 'Length must be at least 8.'
})
}
if (errors.length > 0) {
return { errors }
}
const hashedPassword = await argon2.hash(password)
const user = await prisma.user.create({ data: { username, password: hashedPassword } })
// Logs in after successfully registering
req.session.userID = user.id
return { user }
}
@Mutation(() => UserResponse)
async login(
@Ctx() { req, prisma }: Context,
@Arg('input', () => UsernamePasswordInput) { username, password }: UsernamePasswordInput
): Promise<UserResponse> {
const user = await prisma.user.findUnique({ where: { username } })
if (!user) {
return {
errors: [{
field: 'username',
message: 'That username doesn\'t exist.'
}]
}
}
if (await argon2.verify(user.password, password)) {
// password match
/*
When we store something inside the req.session object for the first time, the session will be initiallised,
and the HTTP response containing the mutation's result will set a session cookie as configured in src/index.ts to the client.
The req.session object will then be stored in the configured store, which, in our case, is Redis.
The client will include the cookie in future requests, enabling us to access the previously saved req.session object.
This is possible because express-session automatically fetches the session object from Redis in the background
using the cookie whenever we access req.session. Express-session, in turn, does this by:
- Decoding the value of the cookie using the SESSION_SECRET
- Adding the 'sess:' prefix to the decoded value to get the Redis key whose corresponding value is the session object.
*/
req.session.userID = user.id
return { user }
}
else {
// password did not match
return {
errors: [{
field: 'password',
message: 'That password is incorrect!'
}]
}
}
}
@Query(() => User, { nullable: true })
async me(
@Ctx() { req, prisma }: Context
): Promise<User | null> {
if (typeof req.session.userID == 'undefined') {
return null
}
return await prisma.user.findUnique({ where: { id: req.session.userID } })
}
@Mutation(() => Boolean)
async logout(
@Ctx() { req, res }: Context
): Promise<Boolean> {
return new Promise(resolve => {
req.session.destroy((err: any) => { // Delete the session object from Redis
res.clearCookie(SESSION_COOKIE_NAME) // In the HTTP response, unset the session cookie from the client's browser.
if (err) {
console.error(err)
resolve(false)
}
resolve(true)
})
})
}
@Mutation(() => ResetPasswordResponse)
async forgotPassword(
@Ctx() { prisma }: Context,
@Arg('email', () => String) email: string
): Promise<ResetPasswordResponse> {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) {
return {
errors: [{
field: 'email',
message: 'No user with that email exists!'
}]
}
}
const token = await prisma.resetPasswordToken.create({ data: { value: uuid_v7(), userID: user.id } })
const resetURL = `${process.env.FRONTEND_ORIGIN}/reset-password/${token.value}`
try {
await sendEmail(email, 'Password Reset', `Visit the following link to reset your password: <a href='${resetURL}'>${resetURL}</a>`)
return { message: `A password reset email has been sent to ${email}. Please follow the instructions inside the email to reset your password.`, messageType: 'success' }
}
catch (err) {
console.error(err)
return { message: 'An error has occurred. Please try again later.', messageType: 'error' }
}
}
@Query(() => Boolean)
async checkResetPasswordToken(
@Ctx() { prisma }: Context,
@Arg('token', () => String) token: string,
): Promise<Boolean> {
const tokenItem = await prisma.resetPasswordToken.findUnique({ where: { value: token } })
if (!tokenItem) {
return false
}
return true
}
@Mutation(() => ResetPasswordResponse)
async resetPassword(
@Ctx() { prisma, redis }: Context,
@Arg('token', () => String) token: string,
@Arg('newPassword', () => String) newPassword: string,
): Promise<ResetPasswordResponse> {
const tokenItem = await prisma.resetPasswordToken.findUnique({ where: { value: token } })
if (!tokenItem) {
return { message: 'Invalid token!', messageType: 'error' }
}
const user = await prisma.user.findUnique({ where: { id: tokenItem.userID } })
if (!user) {
return { message: 'Invalid token!', messageType: 'error' }
}
const errors = []
if (newPassword.length < 8) {
errors.push({
field: 'newPassword',
message: 'Length must be at least 8.'
})
}
if (errors.length > 0) {
return { errors }
}
const hashedPassword = await argon2.hash(newPassword)
await prisma.user.update({ where: { id: tokenItem.userID }, data: { password: hashedPassword } })
// Delete the reset password token
await prisma.resetPasswordToken.delete({ where: { userID_value: { userID: user.id, value: token } } })
// Destroy all existing sessions
const keys = await redis.keys('*')
for (const key of keys) {
const value = await redis.get(key)
const jsonValue = JSON.parse(value!)
if (jsonValue.userID === user.id) {
await redis.del(key)
}
}
return {
message: 'Password successfully updated!',
messageType: 'success'
}
}
}
+85
View File
@@ -0,0 +1,85 @@
import { Post, User } from '@/prisma/generated/type-graphql'
import { PrismaClient } from '@prisma/client'
import { Request, Response } from 'express'
import { createClient } from 'redis'
import { Field, InputType, ObjectType } from 'type-graphql'
import { createUserLoader } from '@/src/loaders'
export interface Context {
prisma: PrismaClient
req: Request & { session: Request['session'] & { userID?: string } }
res: Response
redis: ReturnType<typeof createClient>
userLoader: ReturnType<typeof createUserLoader>
}
@InputType()
export class UsernamePasswordInput {
@Field(() => String, { nullable: true })
email?: string
@Field(() => String)
username: string
@Field(() => String)
password: string
}
@ObjectType()
export class FieldError {
@Field(() => String, { nullable: true })
field?: string
@Field(() => String)
message: string
}
@ObjectType()
export class UserResponse {
@Field(() => [FieldError], { nullable: true })
errors?: FieldError[]
@Field(() => User, { nullable: true })
user?: User
}
@ObjectType()
export class ResetPasswordResponse {
@Field(() => [FieldError], { nullable: true })
errors?: FieldError[]
@Field(() => String, { nullable: true })
message?: string
@Field(() => String, { nullable: true })
messageType?: 'success' | 'error'
}
@InputType()
export class PostInput {
@Field(() => String, { nullable: true })
title?: string
@Field(() => String)
content: string
}
@ObjectType()
export class PostResponse {
@Field(() => [FieldError], { nullable: true })
errors?: FieldError[]
@Field(() => Post, { nullable: true })
post?: Post
}
@ObjectType()
export class PostsResponse {
@Field(() => [Post], { nullable: true })
posts?: Post[]
@Field(() => Boolean)
hasMore: boolean
}
+2
View File
@@ -0,0 +1,2 @@
export * from './sendEmail'
export * from './validateEmail'
+22
View File
@@ -0,0 +1,22 @@
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
// @ts-ignore
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: process.env.SMTP_SECURE === 'true', // Use `true` for port 465, `false` for all other ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
})
export const sendEmail = async (to: string, subject: string, html: string) => {
const info = await transporter.sendMail({
to,
subject,
html
})
console.log('Message sent: %s', info.messageId)
}
+1
View File
@@ -0,0 +1 @@
export const validateEmail = (email: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)