refactor: migrate from Next.js to SolidJS and GraphQL

- Converted web application from Next.js to SolidJS with Vite
- Replaced React components with SolidJS components
- Implemented GraphQL client using URQL
- Added authentication, room, and chat components
- Updated project structure and configuration files
- Removed unnecessary Next.js and docs-related files
- Added Docker support for web and API applications
This commit is contained in:
Juan Sebastián Montoya 2025-03-04 01:08:52 -05:00
parent 8f3aa2fc26
commit 16731409df
81 changed files with 13585 additions and 1163 deletions

4
apps/api/.dockerignore Normal file
View file

@ -0,0 +1,4 @@
.env
node_modules
dist
.turbo

3
apps/api/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
# Keep environment variables out of version control
.env

17
apps/api/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM node:22-alpine AS base
# Rebuild the source code only when needed
FROM base
WORKDIR /app
COPY . .
# Install dependencies
RUN npm install
# Generate Prisma client
RUN npm run prisma:generate
# Build the project
RUN npm run api:build
# Start the server
ENTRYPOINT [ "npm", "run", "api:start" ]

91
apps/api/README.md Normal file
View file

@ -0,0 +1,91 @@
# Unreal Chat API
The backend API for the Unreal Chat application, built with Apollo Server, GraphQL, and Prisma.
## Features
- GraphQL API with Apollo Server
- Real-time subscriptions for messages and rooms
- Prisma ORM with MariaDB
- User authentication
- Chat rooms and messaging
## Getting Started
### Prerequisites
- Node.js (v18 or later)
- npm (v10 or later)
- MariaDB or MySQL
### Installation
1. Install dependencies:
```bash
npm install
```
2. Set up the database:
Make sure you have MariaDB running and update the connection string in `prisma/.env` if needed.
3. Run Prisma migrations:
```bash
npm run prisma:migrate
```
4. Start the development server:
```bash
npm run dev
```
The API will be available at http://localhost:4000/graphql.
## Available Scripts
- `npm run dev` - Start the development server
- `npm run build` - Build the application
- `npm run start` - Start the production server
- `npm run prisma:generate` - Generate Prisma client
- `npm run prisma:migrate` - Run Prisma migrations
- `npm run prisma:studio` - Open Prisma Studio
## API Documentation
### Authentication
For development purposes, authentication is simplified. In a production environment, you would use JWT tokens.
### GraphQL Schema
The GraphQL schema includes the following main types:
- `User`: Represents a user in the system
- `Room`: Represents a chat room
- `Message`: Represents a message in a chat room
### Queries
- `me`: Get the current user
- `users`: Get all users
- `user(id: ID!)`: Get a user by ID
- `rooms`: Get all public rooms
- `room(id: ID!)`: Get a room by ID
- `messages(roomId: ID!)`: Get messages in a room
### Mutations
- `register(email: String!, username: String!, password: String!)`: Register a new user
- `login(email: String!, password: String!)`: Login a user
- `createRoom(name: String!, description: String, isPrivate: Boolean)`: Create a new room
- `joinRoom(roomId: ID!)`: Join a room
- `leaveRoom(roomId: ID!)`: Leave a room
- `sendMessage(content: String!, roomId: ID!)`: Send a message to a room
### Subscriptions
- `messageAdded(roomId: ID!)`: Subscribe to new messages in a room
- `roomAdded`: Subscribe to new rooms

44
apps/api/package.json Normal file
View file

@ -0,0 +1,44 @@
{
"name": "api",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:init": "prisma migrate dev --name init",
"check-types": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@apollo/server": "^4.10.0",
"@graphql-tools/schema": "^10.0.2",
"@prisma/client": "^6.4.1",
"@types/ws": "^8.5.14",
"apollo-server": "^3.13.0",
"apollo-server-express": "^3.13.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"graphql": "^16.8.1",
"graphql-subscriptions": "^2.0.0",
"graphql-ws": "^5.14.0",
"subscriptions-transport-ws": "^0.11.0",
"ws": "^8.18.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.20",
"nodemon": "^3.1.0",
"prisma": "^6.4.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}

View file

@ -0,0 +1,50 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
username String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
rooms Room[] @relation("RoomMembers")
ownedRooms Room[] @relation("RoomOwner")
}
model Room {
id String @id @default(uuid())
name String
description String?
isPrivate Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
members User[] @relation("RoomMembers")
ownerId String
owner User @relation("RoomOwner", fields: [ownerId], references: [id])
}
model Message {
id String @id @default(uuid())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id])
roomId String
room Room @relation(fields: [roomId], references: [id])
}

93
apps/api/src/index.ts Normal file
View file

@ -0,0 +1,93 @@
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { createServer } from 'http';
import express from 'express';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import cors from 'cors';
import { PrismaClient } from '@prisma/client';
import { typeDefs } from './schema/typeDefs';
import { resolvers } from './resolvers';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
// Create Prisma client
const prisma = new PrismaClient();
async function startServer() {
// Create Express app and HTTP server
const app = express();
const httpServer = createServer(app);
// Create WebSocket server
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
// Create schema
const schema = makeExecutableSchema({ typeDefs, resolvers });
// Set up WebSocket server
const serverCleanup = useServer({ schema }, wsServer);
// Create Apollo Server
const server = new ApolloServer({
schema,
plugins: [
// Proper shutdown for the HTTP server
ApolloServerPluginDrainHttpServer({ httpServer }),
// Proper shutdown for the WebSocket server
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
// Start Apollo Server
await server.start();
// Apply middleware
app.use(
'/graphql',
cors<cors.CorsRequest>(),
express.json(),
expressMiddleware(server, {
context: async ({ req }) => {
// In a real application, you would extract the user ID from a JWT token
// const token = req.headers.authorization || '';
// const userId = getUserIdFromToken(token);
// For demo purposes, we'll use a dummy user ID
const userId = (req.headers['user-id'] as string) || null;
return {
prisma,
userId,
};
},
})
);
// Start the server
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`);
console.log(`🚀 Subscriptions ready at ws://localhost:${PORT}/graphql`);
});
}
// Handle errors
startServer().catch((err) => {
console.error('Error starting server:', err);
});

View file

@ -0,0 +1,23 @@
import { userResolvers } from './user';
import { roomResolvers } from './room';
import { messageResolvers } from './message';
export const resolvers = {
Query: {
...userResolvers.Query,
...roomResolvers.Query,
...messageResolvers.Query,
},
Mutation: {
...userResolvers.Mutation,
...roomResolvers.Mutation,
...messageResolvers.Mutation,
},
Subscription: {
...messageResolvers.Subscription,
...roomResolvers.Subscription,
},
User: userResolvers.User,
Room: roomResolvers.Room,
Message: messageResolvers.Message,
};

View file

@ -0,0 +1,112 @@
import { PrismaClient } from '@prisma/client';
import { AuthenticationError, ForbiddenError } from 'apollo-server-express';
import { PubSub, withFilter } from 'graphql-subscriptions';
const prisma = new PrismaClient();
const pubsub = new PubSub();
export const MESSAGE_ADDED = 'MESSAGE_ADDED';
export const messageResolvers = {
Query: {
messages: async (_: any, { roomId }: { roomId: string }, context: any) => {
if (!context.userId) {
throw new AuthenticationError('You must be logged in to view messages');
}
// Check if user is a member of the room
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: true },
});
if (!room) {
throw new ForbiddenError('Room not found');
}
const isMember = room.members.some(
(member: { id: string }) => member.id === context.userId
);
if (!isMember) {
throw new ForbiddenError('You are not a member of this room');
}
return prisma.message.findMany({
where: { roomId },
orderBy: { createdAt: 'asc' },
});
},
},
Mutation: {
sendMessage: async (
_: any,
{ content, roomId }: { content: string; roomId: string },
context: any
) => {
if (!context.userId) {
throw new AuthenticationError(
'You must be logged in to send a message'
);
}
// Check if user is a member of the room
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: true },
});
if (!room) {
throw new ForbiddenError('Room not found');
}
const isMember = room.members.some(
(member: { id: string }) => member.id === context.userId
);
if (!isMember) {
throw new ForbiddenError('You are not a member of this room');
}
const message = await prisma.message.create({
data: {
content,
user: {
connect: { id: context.userId },
},
room: {
connect: { id: roomId },
},
},
include: {
user: true,
room: true,
},
});
pubsub.publish(MESSAGE_ADDED, { messageAdded: message, roomId });
return message;
},
},
Subscription: {
messageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator([MESSAGE_ADDED]),
(payload, variables) => {
return payload.roomId === variables.roomId;
}
),
},
},
Message: {
user: async (parent: any) => {
return prisma.user.findUnique({
where: { id: parent.userId },
});
},
room: async (parent: any) => {
return prisma.room.findUnique({
where: { id: parent.roomId },
});
},
},
};

View file

@ -0,0 +1,161 @@
import { PrismaClient } from '@prisma/client';
import { AuthenticationError, ForbiddenError } from 'apollo-server-express';
import { PubSub } from 'graphql-subscriptions';
const prisma = new PrismaClient();
const pubsub = new PubSub();
export const ROOM_ADDED = 'ROOM_ADDED';
export const roomResolvers = {
Query: {
rooms: async () => {
return prisma.room.findMany({
where: { isPrivate: false },
});
},
room: async (_: any, { id }: { id: string }) => {
return prisma.room.findUnique({
where: { id },
});
},
},
Mutation: {
createRoom: async (
_: any,
{
name,
description,
isPrivate = false,
}: { name: string; description?: string; isPrivate?: boolean },
context: any
) => {
if (!context.userId) {
throw new AuthenticationError('You must be logged in to create a room');
}
const room = await prisma.room.create({
data: {
name,
description,
isPrivate,
owner: {
connect: { id: context.userId },
},
members: {
connect: { id: context.userId },
},
},
});
pubsub.publish(ROOM_ADDED, { roomAdded: room });
return room;
},
joinRoom: async (_: any, { roomId }: { roomId: string }, context: any) => {
if (!context.userId) {
throw new AuthenticationError('You must be logged in to join a room');
}
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: true },
});
if (!room) {
throw new ForbiddenError('Room not found');
}
if (room.isPrivate) {
// In a real application, you would check if the user has been invited
throw new ForbiddenError(
'You cannot join a private room without an invitation'
);
}
// Check if user is already a member
const isMember = room.members.some(
(member: { id: string }) => member.id === context.userId
);
if (isMember) {
return room;
}
return prisma.room.update({
where: { id: roomId },
data: {
members: {
connect: { id: context.userId },
},
},
});
},
leaveRoom: async (_: any, { roomId }: { roomId: string }, context: any) => {
if (!context.userId) {
throw new AuthenticationError('You must be logged in to leave a room');
}
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: true },
});
if (!room) {
throw new ForbiddenError('Room not found');
}
// Check if user is a member
const isMember = room.members.some(
(member: { id: string }) => member.id === context.userId
);
if (!isMember) {
throw new ForbiddenError('You are not a member of this room');
}
// If user is the owner, they cannot leave
if (room.ownerId === context.userId) {
throw new ForbiddenError('You cannot leave a room you own');
}
await prisma.room.update({
where: { id: roomId },
data: {
members: {
disconnect: { id: context.userId },
},
},
});
return true;
},
},
Subscription: {
roomAdded: {
subscribe: () => pubsub.asyncIterator([ROOM_ADDED]),
},
},
Room: {
messages: async (parent: any) => {
return prisma.message.findMany({
where: { roomId: parent.id },
orderBy: { createdAt: 'asc' },
});
},
members: async (parent: any) => {
return prisma.user.findMany({
where: {
rooms: {
some: {
id: parent.id,
},
},
},
});
},
owner: async (parent: any) => {
return prisma.user.findUnique({
where: { id: parent.ownerId },
});
},
},
};

View file

@ -0,0 +1,121 @@
import { PrismaClient } from '@prisma/client';
import { AuthenticationError, UserInputError } from 'apollo-server-express';
// In a real application, you would use bcrypt for password hashing
// import bcrypt from 'bcryptjs';
// import jwt from 'jsonwebtoken';
const prisma = new PrismaClient();
export const userResolvers = {
Query: {
me: async (_: any, __: any, context: any) => {
// In a real application, you would get the user from the context
// which would be set by an authentication middleware
if (!context.userId) {
return null;
}
return prisma.user.findUnique({
where: { id: context.userId },
});
},
users: async () => {
return prisma.user.findMany();
},
user: async (_: any, { id }: { id: string }) => {
return prisma.user.findUnique({
where: { id },
});
},
},
Mutation: {
register: async (
_: any,
{
email,
username,
password,
}: { email: string; username: string; password: string }
) => {
// Check if user already exists
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ email }, { username }],
},
});
if (existingUser) {
throw new UserInputError('User already exists');
}
// In a real application, you would hash the password
// const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
email,
username,
password, // In a real app: hashedPassword
},
});
// In a real application, you would generate a JWT token
// const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
return {
token: 'dummy-token', // In a real app: token
user,
};
},
login: async (
_: any,
{ email, password }: { email: string; password: string }
) => {
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
throw new AuthenticationError('Invalid credentials');
}
// In a real application, you would verify the password
// const valid = await bcrypt.compare(password, user.password);
const valid = password === user.password; // This is just for demo purposes
if (!valid) {
throw new AuthenticationError('Invalid credentials');
}
// In a real application, you would generate a JWT token
// const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
return {
token: 'dummy-token', // In a real app: token
user,
};
},
},
User: {
messages: async (parent: any) => {
return prisma.message.findMany({
where: { userId: parent.id },
});
},
rooms: async (parent: any) => {
return prisma.room.findMany({
where: {
members: {
some: {
id: parent.id,
},
},
},
});
},
ownedRooms: async (parent: any) => {
return prisma.room.findMany({
where: { ownerId: parent.id },
});
},
},
};

View file

@ -0,0 +1,63 @@
import { gql } from 'apollo-server-express';
export const typeDefs = gql`
type User {
id: ID!
email: String!
username: String!
createdAt: String!
updatedAt: String!
messages: [Message!]
rooms: [Room!]
ownedRooms: [Room!]
}
type Room {
id: ID!
name: String!
description: String
isPrivate: Boolean!
createdAt: String!
updatedAt: String!
messages: [Message!]
members: [User!]
owner: User!
}
type Message {
id: ID!
content: String!
createdAt: String!
updatedAt: String!
user: User!
room: Room!
}
type AuthPayload {
token: String!
user: User!
}
type Query {
me: User
users: [User!]!
user(id: ID!): User
rooms: [Room!]!
room(id: ID!): Room
messages(roomId: ID!): [Message!]!
}
type Mutation {
register(email: String!, username: String!, password: String!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
createRoom(name: String!, description: String, isPrivate: Boolean): Room!
joinRoom(roomId: ID!): Room!
leaveRoom(roomId: ID!): Boolean!
sendMessage(content: String!, roomId: ID!): Message!
}
type Subscription {
messageAdded(roomId: ID!): Message!
roomAdded: Room!
}
`;

16
apps/api/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"lib": ["es2018", "esnext.asynciterable"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}