diff --git a/.dockerignore b/.dockerignore index 4d57b5b..0276d52 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,8 +11,13 @@ bun.lockb bunfig.toml fly* node_modules +**/node_modules package.json README.md supabase target yarn.lock +.git +**/dist +**/.next +**/.turbo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d204dea..aae9127 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,16 +56,6 @@ jobs: - name: Client checks OK run: exit 0 - typos: - needs: changed-files - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: crate-ci/typos@v1.28.3 - with: - config: apps/freedit/.typos.toml - files: ${{ needs.changed-files.outputs.all_changed_files }} - _server: needs: changed-files if: needs.changed-files.outputs.server_any_changed == 'true' diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000..6f7e6c7 --- /dev/null +++ b/.yarnrc @@ -0,0 +1,5 @@ +--ignore-optional true +--install.ignore-optional true +--install.prefer-offline true +--install.no-bin-links true +network-timeout 1000000 \ No newline at end of file diff --git a/README.md b/README.md index 2e63bf6..e5b8e0f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,75 @@ -# PSE Forum +# PSE Forums + +## Supabase Self-Hosted Setup + +This project includes a self-hosted Supabase setup that provides: +- PostgreSQL database with custom extensions +- REST API via PostgREST +- Storage API +- Authentication +- Supabase Studio UI + +## Configuration + +All configuration is managed through a single `.env` file in the project root. This file contains all the environment variables needed for both the main application and the Supabase services. + +### Environment Variables + +The key environment variables include: +- `SUPABASE_ANON_KEY` - Anonymous API key for client-side authentication +- `SUPABASE_SERVICE_KEY` - Service role API key for server-side operations +- `JWT_SECRET` - Secret used for JWT authentication +- `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD` - Credentials for accessing Supabase Studio + +## Running the Application + +To start the entire application with Supabase services: + +```bash +docker compose -f docker-compose.combined.yml --env-file .env up -d +``` + +This will start: +1. The PostgreSQL database +2. Supabase services (REST API, Storage, Meta, Studio) +3. The application API +4. The application client + +### Troubleshooting + +If you encounter a network error like: +``` +network pse-forum-network was found but has incorrect label com.docker.compose.network +``` + +Run the following commands to fix it: +```bash +# Stop all containers +docker compose down --remove-orphans + +# Remove the existing network +docker network rm pse-forum-network + +# Start again +docker compose -f docker-compose.combined.yml --env-file .env up -d +``` + +## Accessing Supabase + +### Supabase Studio +- URL: http://localhost:8000 +- Username: `supabase` (or the value of `DASHBOARD_USERNAME` in .env) +- Password: `this_password_is_insecure_and_should_be_updated` (or the value of `DASHBOARD_PASSWORD` in .env) + +### REST API +- Base URL: http://localhost:8000/rest/v1 +- Authentication: Add header `apikey: [SUPABASE_ANON_KEY]` + +## Development + +When developing, you can access: +- Client application: http://localhost:5173 +- API: http://localhost:3001 ## Client diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 0000000..af76343 --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ +yarn.lock +package-lock.json + +# Build output +dist/ +build/ + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# TypeScript cache +*.tsbuildinfo + +# Test coverage +coverage/ \ No newline at end of file diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..91763bb --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine + +WORKDIR /workspace/apps/api + +# Install dependencies +RUN apk add --no-cache bash postgresql-client + +# Copy package files +COPY package*.json ./ + +# Make entrypoint executable +COPY entrypoint.sh ./ +RUN chmod +x ./entrypoint.sh + +# Install dependencies with increased timeout +RUN npm install --network-timeout 600000 + +# Use entrypoint script +ENTRYPOINT ["./entrypoint.sh"] + +COPY . . + +EXPOSE 3001 + +CMD ["npm", "run", "dev"] \ No newline at end of file diff --git a/apps/api/entrypoint.sh b/apps/api/entrypoint.sh new file mode 100755 index 0000000..f7ef8bb --- /dev/null +++ b/apps/api/entrypoint.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -e + +echo "Starting API server setup..." + +# Install dependencies for shared package +echo "Setting up shared package..." +cd ../shared +npm install --no-fund --no-audit +npm run build || echo "Build for shared package failed, but continuing..." + +# Go back to API directory and install dependencies +echo "Setting up API package..." +cd ../api +npm install --no-fund --no-audit + +# Display environment info +echo "Environment variables:" +echo "NODE_ENV: $NODE_ENV" +echo "DATABASE_URL: ${DATABASE_URL}" +echo "PORT: $PORT" + +# Wait for PostgreSQL to be ready +echo "Waiting for PostgreSQL to be ready..." +MAX_RETRIES=120 # Increased max retries +RETRY_COUNT=0 +until pg_isready -h postgres -p 5432 -U postgres; do + echo "PostgreSQL is unavailable - sleeping (retry $RETRY_COUNT/$MAX_RETRIES)" + RETRY_COUNT=$((RETRY_COUNT+1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Failed to connect to PostgreSQL after $MAX_RETRIES attempts" + exit 1 + fi + sleep 2 # Increased sleep time +done +echo "PostgreSQL is up - executing migrations" + +# Ensure the database exists +echo "Ensuring database exists..." +echo "select 'database exists' from pg_database where datname = 'pse_forum'" | psql "$DATABASE_URL" || createdb -h postgres -U postgres pse_forum || echo "Database might already exist, continuing..." + +# Run database migrations and seed +echo "Running migrations..." +npm run db:migrate || { echo "Migration failed, but continuing..."; } +echo "Migrations completed" + +echo "Seeding database..." +npm run db:seed || { echo "Seeding failed, but continuing..."; } +echo "Database seeding completed" + +# Start the API server +echo "Starting API server..." +exec npm run dev \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..49e1d16 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,33 @@ +{ + "name": "api", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only -r tsconfig-paths/register src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:migrate": "ts-node -r tsconfig-paths/register src/db/migrate.ts", + "db:seed": "ts-node -r tsconfig-paths/register src/db/seed.ts", + "db:reset": "ts-node -r tsconfig-paths/register src/db/migrate.ts && ts-node -r tsconfig-paths/register src/db/seed.ts", + "db:setup": "cd ../server && npm run migrate && cd ../api && npm run seed" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.18.2", + "module-alias": "^2.2.3", + "pg": "^8.11.3", + "uuid": "^11.1.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.11.19", + "@types/pg": "^8.11.2", + "@types/uuid": "^10.0.0", + "ts-node-dev": "^2.0.0", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.3.3" + } +} diff --git a/apps/api/src/config/database.ts b/apps/api/src/config/database.ts new file mode 100644 index 0000000..2778ada --- /dev/null +++ b/apps/api/src/config/database.ts @@ -0,0 +1,93 @@ +import { Pool } from 'pg'; +import dotenv from 'dotenv'; +import path from 'path'; +import fs from 'fs'; + +// Load environment variables from appropriate files +const loadEnv = () => { + // Check if we're in development + const NODE_ENV = process.env.NODE_ENV || 'development'; + + // Try to load .env.${NODE_ENV}.local first + const localEnvPath = path.resolve(process.cwd(), `.env.${NODE_ENV}.local`); + if (fs.existsSync(localEnvPath)) { + console.log(`Loading environment from ${localEnvPath}`); + dotenv.config({ path: localEnvPath }); + } else { + // Fall back to .env + const defaultEnvPath = path.resolve(process.cwd(), '.env'); + if (fs.existsSync(defaultEnvPath)) { + console.log(`Loading environment from ${defaultEnvPath}`); + dotenv.config({ path: defaultEnvPath }); + } else { + console.warn('No .env file found'); + } + } +}; + +// Load environment variables +loadEnv(); + +// Default to specific values if environment variables aren't set +const dbConfig = { + user: 'postgres', + password: 'postgres', + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5433', 10), + database: process.env.DB_NAME || 'pse_forum', + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, +}; + +// Print database configuration for debugging (with password redacted) +console.log('Database config (redacted password):', { + ...dbConfig, + password: '******', +}); + +// Create a PostgreSQL connection pool +export const pool = new Pool( + process.env.DATABASE_URL + ? { + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, + } + : dbConfig +); + +// Query helper function +export async function query(text: string, params?: any[]) { + const start = Date.now(); + let client; + + try { + client = await pool.connect(); + const result = await client.query(text, params); + const duration = Date.now() - start; + + if (duration > 100) { + // Only log relevant information to avoid circular references + console.log('Long query:', { + text, + duration, + rowCount: result.rowCount, + params: params ? '[provided]' : '[none]' + }); + } + + return result; + } catch (error) { + const duration = Date.now() - start; + // Safely log error without possible circular references + console.error('Query error:', { + text, + duration, + error: error instanceof Error ? error.message : 'Unknown error', + params: params ? '[provided]' : '[none]' + }); + throw error; + } finally { + if (client) { + client.release(); + } + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts new file mode 100644 index 0000000..b53a7be --- /dev/null +++ b/apps/api/src/db/migrate.ts @@ -0,0 +1,145 @@ +import { pool } from '../config/database'; + +// Add debug logs +console.log('Process environment:', { + NODE_ENV: process.env.NODE_ENV, + DATABASE_URL: process.env.DATABASE_URL, +}); + +const createTables = ` + -- Enable UUID extension + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + + -- Drop tables if they exist to avoid conflicts + DROP TABLE IF EXISTS community_members CASCADE; + DROP TABLE IF EXISTS replies CASCADE; + DROP TABLE IF EXISTS posts CASCADE; + DROP TABLE IF EXISTS badges CASCADE; + DROP TABLE IF EXISTS communities CASCADE; + DROP TABLE IF EXISTS users CASCADE; + + -- Drop function + DROP FUNCTION IF EXISTS update_modified_column(); + + -- Create trigger function for automatically updating updated_at columns + CREATE OR REPLACE FUNCTION update_modified_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + -- Users table (based on user.schema.ts) + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + username TEXT NOT NULL, + avatar TEXT NOT NULL, + badges JSONB DEFAULT '[]', + is_anon BOOLEAN DEFAULT FALSE, + email TEXT, + uuid TEXT NOT NULL, + website TEXT, + bio TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + + -- Communities table (based on community.schema.ts) + CREATE TABLE IF NOT EXISTS communities ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + description TEXT NOT NULL, + avatar TEXT, + banner TEXT, + required_badges JSONB DEFAULT '[]' NOT NULL, + members JSONB DEFAULT '[]', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + + -- Posts table (based on post.schema.ts) + CREATE TABLE IF NOT EXISTS posts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + title TEXT NOT NULL, + content TEXT NOT NULL, + author_id UUID REFERENCES users(id) ON DELETE SET NULL, + community_id UUID REFERENCES communities(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + total_views INTEGER DEFAULT 0, + reactions JSONB DEFAULT '{}', + is_anon BOOLEAN DEFAULT FALSE, + author_badges JSONB DEFAULT '[]'::jsonb + ); + + -- Replies table (based on post.schema.ts postReplySchema) + CREATE TABLE IF NOT EXISTS replies ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + content TEXT NOT NULL, + post_id UUID REFERENCES posts(id) ON DELETE CASCADE NOT NULL, + author_id UUID REFERENCES users(id) ON DELETE SET NULL, + parent_id UUID REFERENCES replies(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + is_anon BOOLEAN DEFAULT FALSE, + author_badges JSONB DEFAULT '[]'::jsonb + ); + + -- Community members (many-to-many relationship) + CREATE TABLE IF NOT EXISTS community_members ( + community_id UUID REFERENCES communities(id) ON DELETE CASCADE NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL, + joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (community_id, user_id) + ); + + -- Badges table (based on badge.schema.ts) + CREATE TABLE IF NOT EXISTS badges ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + description TEXT NOT NULL, + image_url TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + + -- Create triggers for each table to automatically update the updated_at column + CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_modified_column(); + CREATE TRIGGER update_communities_updated_at BEFORE UPDATE ON communities FOR EACH ROW EXECUTE FUNCTION update_modified_column(); + CREATE TRIGGER update_posts_updated_at BEFORE UPDATE ON posts FOR EACH ROW EXECUTE FUNCTION update_modified_column(); + CREATE TRIGGER update_replies_updated_at BEFORE UPDATE ON replies FOR EACH ROW EXECUTE FUNCTION update_modified_column(); + CREATE TRIGGER update_badges_updated_at BEFORE UPDATE ON badges FOR EACH ROW EXECUTE FUNCTION update_modified_column(); + + -- Indexes for performance + CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(author_id); + CREATE INDEX IF NOT EXISTS idx_posts_community ON posts(community_id); + CREATE INDEX IF NOT EXISTS idx_replies_post ON replies(post_id); + CREATE INDEX IF NOT EXISTS idx_replies_author ON replies(author_id); + CREATE INDEX IF NOT EXISTS idx_replies_parent ON replies(parent_id); + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at); + CREATE INDEX IF NOT EXISTS idx_replies_created_at ON replies(created_at); + CREATE INDEX IF NOT EXISTS idx_communities_name ON communities(name); + CREATE INDEX IF NOT EXISTS idx_badges_name ON badges(name); +`; + +async function migrate() { + try { + await pool.query(createTables); + console.log('Migration completed successfully'); + } catch (error) { + console.error('Migration failed:', error); + throw error; + } finally { + await pool.end(); + } +} + +// Run migration if this file is executed directly +if (require.main === module) { + migrate() + .then(() => process.exit(0)) + .catch(() => process.exit(1)); +} + +export { migrate }; \ No newline at end of file diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts new file mode 100644 index 0000000..d61d2ad --- /dev/null +++ b/apps/api/src/db/seed.ts @@ -0,0 +1,445 @@ +import { postMocks } from '@/shared/mocks/posts.mocks'; +import { communityMocks } from '@/shared/mocks/community.mocks'; +import { usersMocks } from '@/shared/mocks/users.mocks'; +import { postSchema } from '@/shared/schemas/post.schema'; +import { communitySchema } from '@/shared/schemas/community.schema'; +import { userSchema } from '@/shared/schemas/user.schema'; +import { pool, query } from '../config/database'; +import { z } from 'zod'; +import { v4 as uuidv4 } from 'uuid'; + +async function checkIfDatabaseEmpty() { + try { + const result = await query('SELECT COUNT(*) FROM users'); + return parseInt(result.rows[0].count) === 0; + } catch (error) { + console.error('Error checking if database is empty:', error); + return true; // Assume empty if there's an error (like table doesn't exist) + } +} + +async function seedDatabase() { + try { + // Validate mocks data + try { + // Validate posts + const postValidationResults = postMocks.map((post, index) => { + try { + postSchema.parse(post); + return { valid: true, index }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + valid: false, + index, + errors: error.errors, + id: post.id, + title: post.title + }; + } + return { valid: false, index, error }; + } + }); + + const invalidPosts = postValidationResults.filter(result => !result.valid); + + if (invalidPosts.length === 0) { + console.log("All mock posts validated against schema"); + } else { + console.warn(`${invalidPosts.length} mock posts failed validation, but will continue with seeding`); + console.warn("Invalid posts:", JSON.stringify(invalidPosts, null, 2)); + } + + // Validate communities + const communityValidationResults = communityMocks.map((community, index) => { + try { + communitySchema.parse(community); + return { valid: true, index }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + valid: false, + index, + errors: error.errors, + id: community.id, + name: community.name + }; + } + return { valid: false, index, error }; + } + }); + + const invalidCommunities = communityValidationResults.filter(result => !result.valid); + + if (invalidCommunities.length === 0) { + console.log("All mock communities validated against schema"); + } else { + console.warn(`${invalidCommunities.length} mock communities failed validation, but will continue with seeding`); + console.warn("Invalid communities:", JSON.stringify(invalidCommunities, null, 2)); + } + + // Add validation for users + const userValidationResults = usersMocks.map((user, index) => { + try { + userSchema.parse(user); + return { valid: true, index }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + valid: false, + index, + errors: error.errors, + id: user.id, + username: user.username + }; + } + return { valid: false, index, error }; + } + }); + + const invalidUsers = userValidationResults.filter(result => !result.valid); + + if (invalidUsers.length === 0) { + console.log("All mock users validated against schema"); + } else { + console.warn(`${invalidUsers.length} mock users failed validation, but will continue with seeding`); + console.warn("Invalid users:", JSON.stringify(invalidUsers, null, 2)); + } + } catch (error) { + console.error("Mock data validation error:", error); + console.warn("Continuing with seeding despite validation errors"); + } + + console.log('Checking database...'); + const isEmpty = await checkIfDatabaseEmpty(); + + if (!isEmpty) { + console.log('Database already has data, skipping seed'); + return; + } + + console.log('Starting database seed...'); + + // Track created entities to avoid duplicates + const createdUsers = new Map(); + const createdCommunities = new Map(); + + // Use a client for transaction + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Step 1: Seed users first + console.log('Seeding users...'); + for (const mockUser of usersMocks) { + const userKey = mockUser.username.toLowerCase(); + + const { rows: [createdUser] } = await client.query( + `INSERT INTO users (username, avatar, badges, is_anon, email, uuid, website, bio) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [ + mockUser.username, + mockUser.avatar || '', + JSON.stringify(mockUser.badges || []), + false, // isAnon + mockUser.email || `${mockUser.username}@example.com`, + mockUser.uuid || uuidv4(), + mockUser.website || null, + mockUser.bio || null + ] + ); + + createdUsers.set(userKey, createdUser); + createdUsers.set(String(mockUser.id), createdUser); // Also map by ID + console.log(`Created user: ${mockUser.username} (${createdUser.id})`); + } + + // Step 2: Seed communities + console.log('Seeding communities...'); + for (const mockCommunity of communityMocks) { + const { rows: [createdCommunity] } = await client.query( + `INSERT INTO communities (name, description, avatar, banner, required_badges, members, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [ + mockCommunity.name, + mockCommunity.description, + mockCommunity.avatar || '', + mockCommunity.banner || '', + JSON.stringify(mockCommunity.requiredBadges || []), + JSON.stringify(mockCommunity.members || []), + new Date(mockCommunity.createdAt || new Date().toISOString()), + new Date(mockCommunity.updatedAt || new Date().toISOString()) + ] + ); + + // Store for reference + createdCommunities.set(String(mockCommunity.id), createdCommunity); + console.log(`Created community: ${mockCommunity.name} (${createdCommunity.id})`); + } + + // Step 3: Seed posts and their related data + console.log('Seeding posts...'); + for (const mockPost of postMocks) { + // Handle author + let authorId = null; + let authorUsername = null; + + // If post is not anonymous and has an author with an ID + if (!mockPost.isAnon && mockPost.author && mockPost.author.id) { + const authorIdStr = String(mockPost.author.id); + authorUsername = mockPost.author.username || `user_${authorIdStr}`; + + // Check if this author ID exists in our already created users + if (createdUsers.has(authorIdStr)) { + const author = createdUsers.get(authorIdStr); + authorId = author.id; + } else { + // Create a default user if needed + const { rows: [newAuthor] } = await client.query( + `INSERT INTO users (username, avatar, badges, is_anon, email, uuid) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + [ + authorUsername, + 'https://github.com/shadcn.png', + JSON.stringify(mockPost.author.badges || []), + false, + `${authorUsername.toLowerCase().replace(/\s+/g, '_')}@example.com`, + uuidv4() + ] + ); + authorId = newAuthor.id; + createdUsers.set(authorIdStr, newAuthor); + console.log(`Created default user for author ID: ${authorIdStr} (${authorId})`); + } + } + // If isAnon is true, authorId and authorUsername remain null + + // Find community if associated + let communityId = null; + if (mockPost.community) { + // community is a string or number (ID) + const communityIdStr = String(mockPost.community); + const community = createdCommunities.get(communityIdStr); + + if (community) { + communityId = community.id; + } + } + + // Create post + const { rows: [createdPost] } = await client.query( + `INSERT INTO posts (title, content, author_id, community_id, total_views, reactions, is_anon, author_badges, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) + RETURNING id`, + [ + mockPost.title, + mockPost.content, + authorId, // Will be null for anonymous posts + communityId, + mockPost.totalViews || 0, + JSON.stringify(mockPost.reactions || {}), + mockPost.isAnon || false, + JSON.stringify(mockPost.author?.badges || []), + new Date(mockPost.createdAt || new Date().toISOString()) + ] + ); + console.log(`Created post: ${mockPost.title.substring(0, 30)}... (${createdPost.id})`); + + // Create replies + if (mockPost.replies?.length) { + for (const mockReply of mockPost.replies) { + // Handle reply author + let replyAuthorId = null; + let replyUsername = null; + + // If reply has an author with an ID and is not anonymous + if (mockReply.author && mockReply.author.id && !mockReply.author.isAnon) { + const replyAuthorIdStr = String(mockReply.author.id); + replyUsername = mockReply.author.username || `reply_author_${replyAuthorIdStr}`; + + // Check if this author ID exists in our already created users + if (createdUsers.has(replyAuthorIdStr)) { + const replyAuthor = createdUsers.get(replyAuthorIdStr); + replyAuthorId = replyAuthor.id; + } else { + // Create a default user if needed + const { rows: [newReplyAuthor] } = await client.query( + `INSERT INTO users (username, avatar, badges, is_anon, email, uuid) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + [ + replyUsername, + 'https://github.com/shadcn.png', + JSON.stringify(mockReply.author.badges || []), + false, + `${replyUsername.toLowerCase().replace(/\s+/g, '_')}@example.com`, + uuidv4() + ] + ); + replyAuthorId = newReplyAuthor.id; + createdUsers.set(replyAuthorIdStr, newReplyAuthor); + } + } + // If author is anonymous, replyAuthorId and replyUsername remain null + + // Create reply + const { rows: [createdReply] } = await client.query( + `INSERT INTO replies (content, post_id, author_id, is_anon, author_badges, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $6) + RETURNING id`, + [ + mockReply.content || '', + createdPost.id, + replyAuthorId, + mockReply.author?.isAnon || false, + JSON.stringify(mockReply.author?.badges || []), + new Date(mockReply.createdAt || new Date().toISOString()) + ] + ); + + // Handle nested replies + if (mockReply.replies?.length) { + for (const nestedReply of mockReply.replies) { + // Handle nested reply author + let nestedAuthorId = null; + let nestedUsername = null; + + // If nested reply has an author with an ID and is not anonymous + if (nestedReply.author && nestedReply.author.id && !nestedReply.author.isAnon) { + const nestedAuthorIdStr = String(nestedReply.author.id); + nestedUsername = nestedReply.author.username || `nested_reply_author_${nestedAuthorIdStr}`; + + // Check if this author ID exists in our already created users + if (createdUsers.has(nestedAuthorIdStr)) { + const nestedAuthor = createdUsers.get(nestedAuthorIdStr); + nestedAuthorId = nestedAuthor.id; + } else { + // Create a default user if needed + const { rows: [newNestedAuthor] } = await client.query( + `INSERT INTO users (username, avatar, badges, is_anon, email, uuid) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + [ + nestedUsername, + 'https://github.com/shadcn.png', + JSON.stringify(nestedReply.author.badges || []), + false, + `${nestedUsername.toLowerCase().replace(/\s+/g, '_')}@example.com`, + uuidv4() + ] + ); + nestedAuthorId = newNestedAuthor.id; + createdUsers.set(nestedAuthorIdStr, newNestedAuthor); + } + } + // If author is anonymous, nestedAuthorId and nestedUsername remain null + + await client.query( + `INSERT INTO replies (content, post_id, author_id, parent_id, is_anon, author_badges, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`, + [ + nestedReply.content || '', + createdPost.id, + nestedAuthorId, + createdReply.id, + nestedReply.author?.isAnon || false, + JSON.stringify(nestedReply.author?.badges || []), + new Date(nestedReply.createdAt || new Date().toISOString()) + ] + ); + } + } + } + } + } + + // Step 4: Add community members + console.log('Adding community members...'); + for (const mockCommunity of communityMocks) { + const community = createdCommunities.get(String(mockCommunity.id)); + + if (community && mockCommunity.members && mockCommunity.members.length > 0) { + for (const memberId of mockCommunity.members) { + // Find user or skip + const memberIdStr = String(memberId); + let member = null; + + // Try to find an existing user + for (const [key, user] of createdUsers.entries()) { + if (key.includes(memberIdStr)) { + member = user; + break; + } + } + + // If member not found in existing users, create a new user + if (!member) { + const { rows: [newMember] } = await client.query( + `INSERT INTO users (username, avatar, badges, is_anon, email, uuid) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + [ + `member_${memberIdStr}`, + 'https://github.com/shadcn.png', + '[]', + false, + `member_${memberIdStr}@example.com`, + uuidv4() + ] + ); + member = newMember; + createdUsers.set(memberIdStr, member); + } + + /*if (member) { + // Add member to community + await client.query( + `INSERT INTO community_members (community_id, user_id, joined_at) + VALUES ($1, $2, $3) + ON CONFLICT (community_id, user_id) DO NOTHING`, + [ + community.id, + member.id, + new Date() + ] + ); + console.log(`Added member ${member.id} to community ${community.id}`); + }*/ + } + } + } + + await client.query('COMMIT'); + console.log('Database seed completed successfully'); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Transaction failed, rolling back:', error); + throw error; + } finally { + client.release(); + } + } catch (error) { + console.error('Error seeding database:', error); + throw error; + } +} + +// Execute seed if this file is run directly +if (require.main === module) { + seedDatabase() + .then(() => { + pool.end(); + process.exit(0); + }) + .catch((error) => { + console.error('Failed to seed database:', error); + pool.end(); + process.exit(1); + }); +} + +export { seedDatabase, checkIfDatabaseEmpty }; \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..e89b38e --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,35 @@ +import express from 'express'; +import cors from 'cors'; +import { postsRouter } from './modules/posts/posts.routes'; +import { meRouter } from './modules/me/me.routes'; +import { badgesRouter } from './modules/badges/badges.routes'; +import communitiesRoutes from './modules/communities/communities.routes'; +import { usersRouter } from './modules/users/users.routes'; + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Middleware +app.use(cors()); +app.use(express.json()); + +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +// Routes +app.use('/api/posts', postsRouter); +app.use('/api/me', meRouter); +app.use('/api/badges', badgesRouter); +app.use('/api/communities', communitiesRoutes); +app.use('/api/users', usersRouter); + +// Error handling +app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { + console.error(err.stack); + res.status(500).json({ error: 'Something broke!' }); +}); + +app.listen(PORT, () => { + console.log(`🚀 Server running on http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/apps/api/src/modules/badges/badges.controller.ts b/apps/api/src/modules/badges/badges.controller.ts new file mode 100644 index 0000000..0c5c051 --- /dev/null +++ b/apps/api/src/modules/badges/badges.controller.ts @@ -0,0 +1,34 @@ +import { Request, Response } from "express"; +import { BadgesService } from "./badges.service"; + +export class BadgesController { + private badgesService: BadgesService; + + constructor() { + this.badgesService = new BadgesService(); + } + + getAllBadges = async (req: Request, res: Response) => { + try { + const badges = await this.badgesService.getAllBadges(); + return res.status(200).json(badges); + } catch (error) { + return res.status(500).json({ error: "Failed to fetch badges" }); + } + }; + + getBadgeById = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const badge = await this.badgesService.getBadgeById(id); + + if (!badge) { + return res.status(404).json({ error: "Badge not found" }); + } + + return res.status(200).json(badge); + } catch (error) { + return res.status(500).json({ error: "Failed to fetch badge" }); + } + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/badges/badges.routes.ts b/apps/api/src/modules/badges/badges.routes.ts new file mode 100644 index 0000000..7fab3d6 --- /dev/null +++ b/apps/api/src/modules/badges/badges.routes.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { BadgesController } from "./badges.controller"; + +const router = Router(); +const badgesController = new BadgesController(); + +router.get("/", badgesController.getAllBadges); +router.get("/:id", badgesController.getBadgeById); + +export { router as badgesRouter }; \ No newline at end of file diff --git a/apps/api/src/modules/badges/badges.service.ts b/apps/api/src/modules/badges/badges.service.ts new file mode 100644 index 0000000..2cf99f7 --- /dev/null +++ b/apps/api/src/modules/badges/badges.service.ts @@ -0,0 +1,14 @@ +import { BadgeSchema } from "@/shared/schemas/badge.schema"; +import { badgesMocks } from "@/shared/mocks/badges.mocks"; + +export class BadgesService { + private badges: BadgeSchema[] = []; + + async getAllBadges(): Promise { + return badgesMocks; + } + + async getBadgeById(id: string): Promise { + return this.badges.find(badge => badge.id === id); + } +} \ No newline at end of file diff --git a/apps/api/src/modules/communities/communities.controller.ts b/apps/api/src/modules/communities/communities.controller.ts new file mode 100644 index 0000000..63cadbb --- /dev/null +++ b/apps/api/src/modules/communities/communities.controller.ts @@ -0,0 +1,86 @@ +import { Request, Response } from 'express'; +import { findAllCommunities, findCommunityById, getPostsByCommunityId, joinCommunity, getUserCommunities } from './communities.service'; + +export async function getAllCommunities(req: Request, res: Response) { + try { + const communities = await findAllCommunities(); + return res.status(200).json(communities); + } catch (error) { + console.error('Error fetching communities:', error); + return res.status(500).json({ error: 'Failed to fetch communities' }); + } +} + +export async function getCommunityById(req: Request, res: Response) { + try { + const { id } = req.params; + + const community = await findCommunityById(id); + + if (!community) { + return res.status(404).json({ error: 'Community not found' }); + } + + return res.status(200).json(community); + } catch (error) { + console.error(`Error fetching community by ID ${req.params.id}:`, error); + return res.status(500).json({ error: 'Failed to fetch community' }); + } +} + +export async function getCommunityPosts(req: Request, res: Response) { + try { + const { id } = req.params; + + const posts = await getPostsByCommunityId(id); + + return res.status(200).json(posts); + } catch (error) { + console.error(`Error fetching community posts by ID ${req.params.id}:`, error); + return res.status(500).json({ error: 'Failed to fetch community posts' }); + } +} + +export async function joinCommunityController(req: Request, res: Response) { + try { + const { id: communityId } = req.params; + const { userId } = req.body; + + if (!communityId || !userId) { + return res.status(400).json({ + success: false, + message: 'Both community ID and user ID are required' + }); + } + + const result = await joinCommunity(userId, communityId); + + if (!result.success) { + return res.status(400).json(result); + } + + return res.status(200).json(result); + } catch (error) { + console.error(`Error joining community ${req.params.id}:`, error); + return res.status(500).json({ + success: false, + message: 'Failed to join community' + }); + } +} + +export async function getUserCommunitiesController(req: Request, res: Response) { + try { + const { userId } = req.params; + + if (!userId) { + return res.status(400).json({ error: 'User ID is required' }); + } + + const communities = await getUserCommunities(userId); + return res.status(200).json(communities); + } catch (error) { + console.error('Error fetching user communities:', error); + return res.status(500).json({ error: 'Failed to fetch user communities' }); + } +} \ No newline at end of file diff --git a/apps/api/src/modules/communities/communities.routes.ts b/apps/api/src/modules/communities/communities.routes.ts new file mode 100644 index 0000000..838ed61 --- /dev/null +++ b/apps/api/src/modules/communities/communities.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { getAllCommunities, getCommunityById, getCommunityPosts, joinCommunityController } from './communities.controller'; + +const router = Router(); + +// Routes +router.get('/', getAllCommunities); +router.get('/:id', getCommunityById); +router.get('/:id/posts', getCommunityPosts); +router.post('/:id/join', joinCommunityController); + +export default router; \ No newline at end of file diff --git a/apps/api/src/modules/communities/communities.service.ts b/apps/api/src/modules/communities/communities.service.ts new file mode 100644 index 0000000..46315c1 --- /dev/null +++ b/apps/api/src/modules/communities/communities.service.ts @@ -0,0 +1,258 @@ +import { CommunitySchema } from '@/shared/schemas/community.schema'; +import { query } from '../../config/database'; +import { PostSchema } from '@/shared/schemas/post.schema'; +import { formatPostDbRow, formatReplyDbRow } from '../posts/posts.helpers'; +import { getUserById } from '../users/users.service'; + + +export async function getPostsByCommunityId(id: string): Promise { + try { + const result = await query(` + SELECT + p.id as post_id, + p.title as post_title, + p.content as post_content, + p.created_at as post_created_at, + p.updated_at as post_updated_at, + p.author_id as post_author_id, + p.is_anon as post_is_anon, + p.total_views as post_total_views, + p.reactions as post_reactions, + p.community_id as post_community_id, + u.username as user_username, + u.avatar as user_avatar, + u.badges as user_badges, + c.id as community_id, + c.name as community_name, + c.description as community_description, + c.created_at as community_created_at, + c.updated_at as community_updated_at + FROM posts p + LEFT JOIN users u ON p.author_id = u.id + LEFT JOIN communities c ON p.community_id = c.id + WHERE p.community_id = $1 + ORDER BY p.created_at DESC + `, [id]); + + const posts = await Promise.all(result.rows.map(async (postRow) => { + // Fetch replies for each post + const repliesResult = await query(` + SELECT + r.id as reply_id, + r.content as reply_content, + r.created_at as reply_created_at, + r.updated_at as reply_updated_at, + r.author_id as reply_author_id, + r.is_anon as reply_is_anon, + r.parent_id as reply_parent_id, + u.username as reply_user_username, + u.avatar as reply_user_avatar, + u.badges as reply_user_badges + FROM replies r + LEFT JOIN users u ON r.author_id = u.id + WHERE r.post_id = $1 + ORDER BY r.created_at ASC + `, [postRow.post_id]); + + const post = formatPostDbRow(postRow); + + const formattedReplies = repliesResult.rows.map(reply => + formatReplyDbRow(reply) + ); + + const replyMap = new Map(); + const topLevelReplies: any[] = []; + + formattedReplies.forEach(reply => { + replyMap.set(reply.id, { ...reply, replies: [] }); + }); + + formattedReplies.forEach(reply => { + if (reply.parentId) { + const parentReply = replyMap.get(reply.parentId); + if (parentReply) { + parentReply.replies.push(replyMap.get(reply.id)); + } + } else { + topLevelReplies.push(replyMap.get(reply.id)); + } + }); + + post.replies = topLevelReplies as any[]; + return post as unknown as PostSchema; + })); + + return posts; + } catch (error) { + console.error(`Error fetching posts for community ${id}:`, error); + return []; + } +} + +const formatCommunityFromDb = (community: any, members: any[] = []): CommunitySchema => { + return { + id: community.id, + name: community.name, + description: community.description, + avatar: community.avatar || '', + banner: community.banner || '', + requiredBadges: community.required_badges || [], + members: members.map(member => member.user_id), + createdAt: community.created_at, + updatedAt: community.updated_at, + }; +} + +export async function findAllCommunities(): Promise { + try { + // Fetch all communities + const communitiesResult = await query(` + SELECT * FROM communities + ORDER BY created_at DESC + `); + + // For each community, fetch its members + const communities = await Promise.all(communitiesResult.rows.map(async (community) => { + const membersResult = await query(` + SELECT user_id FROM community_members + WHERE community_id = $1 + `, [community.id]); + + return formatCommunityFromDb(community, membersResult.rows); + })); + + return communities; + } catch (error) { + console.error('Error finding all communities:', error); + throw new Error('Failed to fetch communities from database'); + } +} + +export async function findCommunityById(id: string): Promise { + try { + const communityResult = await query(` + SELECT * FROM communities + WHERE id = $1 + `, [id]); + + if (communityResult.rows.length === 0) { + return null; + } + + const community = communityResult.rows[0]; + + const membersResult = await query(` + SELECT user_id FROM community_members + WHERE community_id = $1 + `, [id]); + + const postsResult = await query(` + SELECT + p.*, + u.id as author_id, + u.username as author_username, + u.avatar as author_avatar, + u.badges as author_badges, + u.is_anon as author_is_anon + FROM posts p + JOIN users u ON p.author_id = u.id + WHERE p.community_id = $1 + ORDER BY p.created_at DESC + `, [id]); + + const formattedCommunity = formatCommunityFromDb(community, membersResult.rows); + + return formattedCommunity; + } catch (error) { + console.error(`Error finding community by ID ${id}:`, error); + throw new Error(`Failed to fetch community with ID ${id}`); + } +} + +export async function joinCommunity(userId: string, communityId: string): Promise<{ success: boolean; message: string; }> { + try { + const communityResult = await query( + `SELECT * FROM communities WHERE id = $1`, + [communityId] + ); + + if (communityResult.rows.length === 0) { + return { success: false, message: 'Community not found' }; + } + + const community = communityResult.rows[0]; + + const user = await getUserById(userId); + if (!user) { + return { success: false, message: 'User not found' }; + } + + const membershipResult = await query( + `SELECT * FROM community_members WHERE community_id = $1 AND user_id = $2`, + [communityId, userId] + ); + + if (membershipResult.rows.length > 0) { + return { success: false, message: 'User is already a member of this community' }; + } + + const requiredBadges = community.required_badges || []; + if (requiredBadges.length > 0) { + const userBadges = user.badges || []; + const userBadgeIds = userBadges.map((badge: any) => badge.id); + + const requiredBadgeIds = requiredBadges.map((badge: any) => String(badge)); + const userBadgeIdsStr = userBadgeIds.map((id: any) => String(id)); + + const missingBadges = requiredBadgeIds.filter( + (badgeId: string) => !userBadgeIdsStr.includes(badgeId) + ); + + if (missingBadges.length > 0) { + return { + success: false, + message: `User doesn't have the required badges to join this community. Missing badges: ${missingBadges.join(', ')}` + }; + } + } + + await query( + `INSERT INTO community_members (community_id, user_id, joined_at) + VALUES ($1, $2, $3)`, + [communityId, userId, new Date()] + ); + + return { success: true, message: 'Successfully joined the community' }; + } catch (error) { + console.error(`Error joining community ${communityId}:`, error); + throw new Error('Failed to join community'); + } +} + +export async function getUserCommunities(userId: string): Promise { + try { + const result = await query(` + SELECT + c.*, + cm.joined_at + FROM communities c + JOIN community_members cm ON c.id = cm.community_id + WHERE cm.user_id = $1 + ORDER BY cm.joined_at DESC + `, [userId]); + + const communities = await Promise.all(result.rows.map(async (community) => { + const membersResult = await query(` + SELECT user_id FROM community_members + WHERE community_id = $1 + `, [community.id]); + + return formatCommunityFromDb(community, membersResult.rows); + })); + + return communities; + } catch (error) { + console.error(`Error fetching communities for user ${userId}:`, error); + return []; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/me/me.controller.ts b/apps/api/src/modules/me/me.controller.ts new file mode 100644 index 0000000..b7499e1 --- /dev/null +++ b/apps/api/src/modules/me/me.controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express'; +import { getUser } from './me.service'; + +export async function getCurrentUser(req: Request, res: Response) { + try { + const user = await getUser(); + res.json(user); + } catch (error) { + console.error('Error fetching user:', error); + res.status(500).json({ error: 'Failed to fetch user data' }); + } +} \ No newline at end of file diff --git a/apps/api/src/modules/me/me.routes.ts b/apps/api/src/modules/me/me.routes.ts new file mode 100644 index 0000000..e129e50 --- /dev/null +++ b/apps/api/src/modules/me/me.routes.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { getCurrentUser } from './me.controller'; + +const router = Router(); + +router.get('/', getCurrentUser); + +export { router as meRouter }; \ No newline at end of file diff --git a/apps/api/src/modules/me/me.service.ts b/apps/api/src/modules/me/me.service.ts new file mode 100644 index 0000000..689d88e --- /dev/null +++ b/apps/api/src/modules/me/me.service.ts @@ -0,0 +1,35 @@ +import { usersMocks } from '@/shared/mocks/users.mocks'; +import { query } from '../../config/database'; +import { getUserCommunities } from '../communities/communities.service'; + +export async function getUser() { + try { + const result = await query( + `SELECT * FROM users WHERE uuid = $1`, + [usersMocks[0].uuid] // TODO: Get the user id from the session + ); + + if (result.rows.length === 0) { + throw new Error('User not found'); + } + + const userData = result.rows[0]; + const communities = await getUserCommunities(userData.id); + + return { + id: userData.id, + username: userData.username, + email: userData.email || null, + website: userData.website || null, + bio: userData.bio || null, + uuid: userData.uuid, + avatar: userData.avatar, + isAnon: userData.is_anon || false, + badges: Array.isArray(userData.badges) ? userData.badges : [], + communities + }; + } catch (error) { + console.error('Error fetching user:', error); + throw new Error('Failed to fetch user data'); + } +} \ No newline at end of file diff --git a/apps/api/src/modules/posts/posts.controller.ts b/apps/api/src/modules/posts/posts.controller.ts new file mode 100644 index 0000000..9475b6b --- /dev/null +++ b/apps/api/src/modules/posts/posts.controller.ts @@ -0,0 +1,110 @@ +import { Request, Response } from "express" +import { ZodError } from "zod" +import { postReactionSchema, createPostSchema } from "@/shared/schemas/post.schema" +import { + findAllPosts, + findPostById, + addPostReaction, + removePostReaction, + createPost, +} from "./posts.service" + +export async function getAllPosts(req: Request, res: Response) { + try { + const posts = await findAllPosts() + return res.status(200).json(posts) + } catch (error) { + return res.status(500).json({ error: "Failed to fetch posts" }) + } +} + +export async function getPostById(req: Request, res: Response) { + try { + const { id } = req.params + const post = await findPostById(id) + + if (!post) { + return res.status(404).json({ error: "Post not found" }) + } + + return res.status(200).json(post) + } catch (error) { + return res.status(500).json({ error: "Failed to fetch post" }) + } +} + +export async function toggleReaction(req: Request, res: Response) { + try { + const { id } = req.params + const { emoji, userId } = req.body + + if (!id || !emoji || !userId) { + return res.status(400).json({ + error: "Missing required fields", + details: "Post ID, emoji, and userId are required", + }) + } + + const currentPost = await findPostById(id) + if (!currentPost) { + return res.status(404).json({ error: "Post not found" }) + } + + const reactions = currentPost.reactions || {} + const currentEmojiReaction = reactions[emoji] || { userIds: [] } + const hasReacted = + Array.isArray(currentEmojiReaction.userIds) && + currentEmojiReaction.userIds.includes(userId) + + let updatedPost + + if (hasReacted) { + updatedPost = await removePostReaction(id, emoji, [userId]) + } else { + updatedPost = await addPostReaction(id, emoji, [userId]) + } + + if (!updatedPost) { + return res.status(500).json({ error: "Failed to update reaction" }) + } + + return res.status(200).json(updatedPost) + } catch (error) { + if (error instanceof ZodError) { + return res.status(400).json({ + error: "Invalid reaction data", + details: error.errors, + }) + } + + console.error("Error toggling reaction:", error) + + return res.status(500).json({ + error: "Failed to toggle reaction", + details: error instanceof Error ? error.message : String(error), + }) + } +} + +export async function createPostController(req: Request, res: Response) { + try { + const validatedData = createPostSchema.parse(req.body); + + const post = await createPost(validatedData); + + return res.status(201).json(post); + } catch (error) { + if (error instanceof ZodError) { + return res.status(400).json({ + error: "Invalid post data", + details: error.errors, + }); + } + + console.error("Error creating post:", error); + return res.status(500).json({ + error: "Failed to create post", + details: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/apps/api/src/modules/posts/posts.helpers.ts b/apps/api/src/modules/posts/posts.helpers.ts new file mode 100644 index 0000000..d1cb62c --- /dev/null +++ b/apps/api/src/modules/posts/posts.helpers.ts @@ -0,0 +1,126 @@ +import { PostSchema, PostAuthorSchema, postSchema } from "@/shared/schemas/post.schema" +import { CommunitySchema } from "@/shared/schemas/community.schema"; +import { z } from "zod"; + +interface PostReply { + id: string; + content: string; + createdAt: string; + updatedAt: string; + parentId?: string; + author: PostAuthorSchema; + replies: PostReply[]; +} + +const formatPostReplies = (repliesRows: any[]) => { + return repliesRows + .filter((reply) => !reply.parent_id) + .map((reply) => { + const childReplies = repliesRows + .filter((r) => r.parent_id === reply.id) + .map((childReply) => ({ + id: childReply.id, + content: childReply.content || "", + createdAt: childReply.created_at, + author: { + id: childReply.user_is_anon ? null : childReply.user_id, + isAnon: !!childReply.user_is_anon, + badges: Array.isArray(childReply.badges) ? childReply.badges : [] + } + })) + + return { + id: reply.id, + content: reply.content || "", + createdAt: reply.created_at, + author: { + id: reply.user_is_anon ? null : reply.user_id, + isAnon: !!reply.user_is_anon, + badges: Array.isArray(reply.badges) ? reply.badges : [] + }, + replies: childReplies, + } + }) +} + +export function formatPostDbRow(row: any): any { + // Format community data if present + let communityData: CommunitySchema | undefined = undefined; + + if (row.community_id) { + communityData = { + id: row.community_id, + name: row.community_name || "", + description: row.community_description || "", + createdAt: row.community_created_at || new Date().toISOString(), + updatedAt: row.community_updated_at || new Date().toISOString(), + requiredBadges: [], + members: [], + avatar: '', + banner: '', + } as CommunitySchema; + } + + return { + id: row.post_id, + title: row.post_title, + content: row.post_content, + createdAt: row.post_created_at, + updatedAt: row.post_updated_at, + totalViews: row.post_total_views, + reactions: row.post_reactions || {}, + isAnon: row.post_is_anon, + community: row.post_community_id, + communityData, + author: row.post_author_id ? { + id: row.post_author_id, + username: row.user_username, + isAnon: row.post_is_anon, + badges: row.post_author_badges || [] + } : { + id: null, + username: null, + isAnon: true, + badges: [] + }, + replies: [] + }; +} + +export function formatReplyDbRow(row: any): any { + return { + id: row.reply_id, + content: row.reply_content, + createdAt: row.reply_created_at, + updatedAt: row.reply_updated_at, + parentId: row.reply_parent_id, + isAnon: row.reply_is_anon, + author: row.reply_author_id ? { + id: row.reply_author_id, + username: row.reply_user_username, + isAnon: row.reply_is_anon, + badges: row.reply_author_badges || [] + } : { + id: null, + username: null, + isAnon: true, + badges: [] + }, + replies: [] + }; +} + +// Helper function to safely parse JSON +function safeJsonParse(jsonString: any, defaultValue: any) { + if (!jsonString) return defaultValue; + + // If already an object, return as is + if (typeof jsonString === 'object') return jsonString; + + try { + return JSON.parse(jsonString); + } catch (error) { + console.error('Error parsing JSON:', error, 'Value was:', jsonString); + return defaultValue; + } +} diff --git a/apps/api/src/modules/posts/posts.routes.ts b/apps/api/src/modules/posts/posts.routes.ts new file mode 100644 index 0000000..30abbf7 --- /dev/null +++ b/apps/api/src/modules/posts/posts.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { getAllPosts, getPostById, toggleReaction, createPostController } from './posts.controller'; + +const router = Router(); + +router.get('/', getAllPosts); +router.get('/:id', getPostById); +router.post('/:id/reactions', toggleReaction); +router.post('/', createPostController); + +export { router as postsRouter }; \ No newline at end of file diff --git a/apps/api/src/modules/posts/posts.service.ts b/apps/api/src/modules/posts/posts.service.ts new file mode 100644 index 0000000..c238228 --- /dev/null +++ b/apps/api/src/modules/posts/posts.service.ts @@ -0,0 +1,421 @@ +import { PostSchema, PostAuthorSchema, postReplySchema, createPostSchema } from '@/shared/schemas/post.schema'; +import { query } from '../../config/database'; +import { formatPostDbRow, formatReplyDbRow } from './posts.helpers'; +import { z } from 'zod'; + +// Define the reply type to match PostSchema's replies structure +type PostReply = { + id: string; + content: string; + author: PostAuthorSchema; + createdAt: string; + updatedAt: string; + isAnon: boolean; + parentId: string | null; + replies: PostReply[]; +}; + +// Helper function to safely parse JSON (add this if not already present) +function safeJsonParse(jsonString: any, defaultValue: any) { + if (!jsonString) return defaultValue; + + // If already an object, return as is + if (typeof jsonString === 'object') return jsonString; + + try { + return JSON.parse(jsonString); + } catch (error) { + console.error('Error parsing JSON:', error, 'Value was:', jsonString); + return defaultValue; + } +} + +export async function findAllPosts(): Promise { + try { + const result = await query(` + SELECT + p.id as post_id, + p.title as post_title, + p.content as post_content, + p.created_at as post_created_at, + p.updated_at as post_updated_at, + p.author_id as post_author_id, + p.is_anon as post_is_anon, + p.total_views as post_total_views, + p.reactions as post_reactions, + p.community_id as post_community_id, + p.author_badges as post_author_badges, + u.username as user_username, + u.avatar as user_avatar, + c.id as community_id, + c.name as community_name, + c.description as community_description, + c.created_at as community_created_at, + c.updated_at as community_updated_at + FROM posts p + LEFT JOIN users u ON p.author_id = u.id + LEFT JOIN communities c ON p.community_id = c.id + ORDER BY p.created_at DESC + `); + + const posts = await Promise.all(result.rows.map(async (postRow) => { + // Fetch replies for each post + const repliesResult = await query(` + SELECT + r.id as reply_id, + r.content as reply_content, + r.created_at as reply_created_at, + r.updated_at as reply_updated_at, + r.author_id as reply_author_id, + r.is_anon as reply_is_anon, + r.parent_id as reply_parent_id, + r.author_badges as reply_author_badges, + u.username as reply_user_username, + u.avatar as reply_user_avatar + FROM replies r + LEFT JOIN users u ON r.author_id = u.id + WHERE r.post_id = $1 + ORDER BY r.created_at ASC + `, [postRow.post_id]); + + const post = formatPostDbRow(postRow); + + // Format and organize replies + const formattedReplies = repliesResult.rows.map((reply) => + formatReplyDbRow(reply) + ); + + // Organize replies into a hierarchy + const replyMap = new Map(); + const topLevelReplies: PostReply[] = []; + + formattedReplies.forEach(reply => { + replyMap.set(reply.id, { ...reply, replies: [] }); + }); + + formattedReplies.forEach(reply => { + if (reply.parentId) { + const parentReply = replyMap.get(reply.parentId); + const replyObj = replyMap.get(reply.id); + if (parentReply && replyObj) { + parentReply.replies.push(replyObj); + } + } else { + const replyObj = replyMap.get(reply.id); + if (replyObj) { + topLevelReplies.push(replyObj); + } + } + }); + + post.replies = topLevelReplies; + return post as unknown as PostSchema; + })); + + return posts; + } catch (error) { + console.error('Error fetching posts:', error); + throw new Error('Failed to fetch posts from database'); + } +} + +export async function findPostById( + id: string | number, +): Promise { + try { + const result = await query(` + SELECT + p.id as post_id, + p.title as post_title, + p.content as post_content, + p.created_at as post_created_at, + p.updated_at as post_updated_at, + p.author_id as post_author_id, + p.is_anon as post_is_anon, + p.total_views as post_total_views, + p.reactions as post_reactions, + p.community_id as post_community_id, + p.author_badges as post_author_badges, + u.username as user_username, + u.avatar as user_avatar, + c.id as community_id, + c.name as community_name, + c.description as community_description, + c.created_at as community_created_at, + c.updated_at as community_updated_at + FROM posts p + LEFT JOIN users u ON p.author_id = u.id + LEFT JOIN communities c ON p.community_id = c.id + WHERE p.id = $1 + `, [id]); + + // If no post found, return undefined + if (result.rows.length === 0) { + return undefined; + } + + const postRow = result.rows[0]; + + // Fetch replies for the post + const repliesResult = await query(` + SELECT + r.id as reply_id, + r.content as reply_content, + r.created_at as reply_created_at, + r.updated_at as reply_updated_at, + r.author_id as reply_author_id, + r.is_anon as reply_is_anon, + r.parent_id as reply_parent_id, + r.author_badges as reply_author_badges, + u.username as reply_user_username, + u.avatar as reply_user_avatar + FROM replies r + LEFT JOIN users u ON r.author_id = u.id + WHERE r.post_id = $1 + ORDER BY r.created_at ASC + `, [postRow.post_id]); + + const post = formatPostDbRow(postRow); + + // Format and organize replies + const formattedReplies = repliesResult.rows.map((reply) => + formatReplyDbRow(reply) + ); + + // Organize replies into a hierarchy + const replyMap = new Map(); + const topLevelReplies: PostReply[] = []; + + formattedReplies.forEach(reply => { + replyMap.set(reply.id, { ...reply, replies: [] }); + }); + + formattedReplies.forEach(reply => { + if (reply.parentId) { + const parentReply = replyMap.get(reply.parentId); + const replyObj = replyMap.get(reply.id); + if (parentReply && replyObj) { + parentReply.replies.push(replyObj); + } + } else { + const replyObj = replyMap.get(reply.id); + if (replyObj) { + topLevelReplies.push(replyObj); + } + } + }); + + post.replies = topLevelReplies; + return post; + } catch (error) { + console.error(`Error fetching post with id ${id}:`, error); + return undefined; + } +} + +export async function addPostReaction( + postId: string, + emoji: string, + userIds: string[], +): Promise { + try { + // Get current post with reactions + const postResult = await query(` + SELECT + p.id as post_id, + p.title as post_title, + p.content as post_content, + p.created_at as post_created_at, + p.updated_at as post_updated_at, + p.author_id as post_author_id, + p.is_anon as post_is_anon, + p.total_views as post_total_views, + p.reactions as post_reactions, + p.community_id as post_community_id, + u.username as user_username, + u.avatar as user_avatar, + u.badges as user_badges + FROM posts p + LEFT JOIN users u ON p.author_id = u.id + WHERE p.id = $1 + `, [postId]); + + if (postResult.rows.length === 0) { + return undefined; + } + + const postRow = postResult.rows[0]; + const currentReactions = safeJsonParse(postRow.post_reactions, {}); + + let reaction = currentReactions[emoji]; + if (!reaction) { + reaction = { + emoji, + count: 0, + userIds: [] + }; + } + + // Ensure userIds is an array + if (!Array.isArray(reaction.userIds)) { + reaction.userIds = []; + } + + const newUserIds = userIds.filter(id => !reaction.userIds.includes(id)); + reaction.userIds = [...reaction.userIds, ...newUserIds]; + + reaction.count = reaction.userIds.length; + + // For first reaction, ensure count is at least the number of new userIds + if (newUserIds.length > 0 && reaction.count === 0) { + reaction.count = newUserIds.length; + } + + currentReactions[emoji] = reaction; + + await query(` + UPDATE posts + SET reactions = $1 + WHERE id = $2 + `, [JSON.stringify(currentReactions), postId]); + + // Fetch the updated post + return findPostById(postId); + } catch (error) { + console.error(`Error adding reaction to post ${postId}:`, error); + return undefined; + } +} + +export async function removePostReaction( + postId: string, + emoji: string, + userIds: string[], +): Promise { + try { + // Get current post with reactions + const postResult = await query(` + SELECT + p.id as post_id, + p.title as post_title, + p.content as post_content, + p.created_at as post_created_at, + p.updated_at as post_updated_at, + p.author_id as post_author_id, + p.is_anon as post_is_anon, + p.total_views as post_total_views, + p.reactions as post_reactions, + p.community_id as post_community_id, + u.username as user_username, + u.avatar as user_avatar, + u.badges as user_badges + FROM posts p + LEFT JOIN users u ON p.author_id = u.id + WHERE p.id = $1 + `, [postId]); + + if (postResult.rows.length === 0) { + return undefined; + } + + const postRow = postResult.rows[0]; + const currentReactions = safeJsonParse(postRow.post_reactions, {}); + + let reaction = currentReactions[emoji]; + if (!reaction) { + return findPostById(postId); + } + + // Ensure userIds is an array + if (!Array.isArray(reaction.userIds)) { + reaction.userIds = []; + return findPostById(postId); + } + + reaction.userIds = reaction.userIds.filter((id: string) => !userIds.includes(id)); + reaction.count = reaction.userIds.length; + + // If no more users, remove the reaction entirely + if (reaction.count === 0) { + delete currentReactions[emoji]; + } else { + currentReactions[emoji] = reaction; + } + + // Update the post in the database + await query(` + UPDATE posts + SET reactions = $1 + WHERE id = $2 + `, [JSON.stringify(currentReactions), postId]); + + // Fetch the updated post + return findPostById(postId); + } catch (error) { + console.error(`Error removing reaction from post ${postId}:`, error); + return undefined; + } +} + +export async function createPost(postData: z.infer): Promise { + try { + if (!postData.title?.trim()) { + throw new Error('Title is required'); + } + if (!postData.content?.trim()) { + throw new Error('Content is required'); + } + if (!postData.author?.id) { + throw new Error('Author ID is required'); + } + + const author = { + id: postData.author.id, + username: postData.author.username || null, + isAnon: postData.author.isAnon || false, + badges: postData.author.badges?.map(badge => + typeof badge === 'string' ? parseInt(badge, 10) : badge + ) || [] + }; + + const result = await query(` + INSERT INTO posts ( + title, + content, + author_id, + is_anon, + community_id, + author_badges, + created_at, + updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + RETURNING + id as post_id, + title as post_title, + content as post_content, + created_at as post_created_at, + updated_at as post_updated_at, + author_id as post_author_id, + is_anon as post_is_anon, + total_views as post_total_views, + reactions as post_reactions, + community_id as post_community_id, + author_badges as post_author_badges + `, [ + postData.title.trim(), + postData.content.trim(), + author.id, + postData.isAnon, + postData.community, + JSON.stringify(author.badges), + ]); + + const postRow = result.rows[0]; + const post = formatPostDbRow(postRow); + post.replies = []; + return post as unknown as PostSchema; + } catch (error) { + console.error('Error creating post:', error); + throw error instanceof Error ? error : new Error('Failed to create post'); + } +} diff --git a/apps/api/src/modules/users/users.controller.ts b/apps/api/src/modules/users/users.controller.ts new file mode 100644 index 0000000..848dfc3 --- /dev/null +++ b/apps/api/src/modules/users/users.controller.ts @@ -0,0 +1,34 @@ +import { Request, Response } from 'express'; +import { getCurrentUser, getUserByUsername } from './users.service'; + +export async function getMe(req: Request, res: Response) { + try { + const user = await getCurrentUser(); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + return res.json(user); + } catch (error) { + return res.status(500).json({ error: 'Failed to fetch user' }); + } +} + +export async function getUserByUsernameController(req: Request, res: Response) { + try { + const { username } = req.params; + + if (!username) { + return res.status(400).json({ error: 'Username is required' }); + } + + const user = await getUserByUsername(username); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + return res.json(user); + } catch (error) { + return res.status(500).json({ error: 'Failed to fetch user' }); + } +} \ No newline at end of file diff --git a/apps/api/src/modules/users/users.routes.ts b/apps/api/src/modules/users/users.routes.ts new file mode 100644 index 0000000..8c7baf2 --- /dev/null +++ b/apps/api/src/modules/users/users.routes.ts @@ -0,0 +1,7 @@ +import express from 'express'; +import { getMe, getUserByUsernameController } from './users.controller'; + +export const usersRouter = express.Router(); + +usersRouter.get('/me', getMe); +usersRouter.get('/:username', getUserByUsernameController); \ No newline at end of file diff --git a/apps/api/src/modules/users/users.service.ts b/apps/api/src/modules/users/users.service.ts new file mode 100644 index 0000000..398a10a --- /dev/null +++ b/apps/api/src/modules/users/users.service.ts @@ -0,0 +1,131 @@ +import { query } from '../../config/database'; +import { usersMocks } from '@/shared/mocks/users.mocks'; + +export async function getCurrentUser() { + try { + const result = await query( + `SELECT + id, + username, + email, + website, + bio, + uuid, + avatar, + is_anon, + badges + FROM users + WHERE uuid = $1 + LIMIT 1`, + [usersMocks[0].uuid] + ); + + console.log(`Found ${result.rows.length} current user records`); + + if (result.rows.length === 0) { + return null; + } + + const userData = result.rows[0]; + return formatUserData(userData); + } catch (error) { + console.error(`Error fetching current user: ${error instanceof Error ? error.message : 'Unknown error'}`); + return null; + } +} + + +export async function getUserByUsername(username: any) { + // Check if username is a valid string + if (!username || typeof username !== 'string') { + console.error('Invalid username parameter:', typeof username); + return null; + } + + try { + const result = await query( + `SELECT + id, + username, + email, + website, + bio, + uuid, + avatar, + is_anon, + badges, + created_at + FROM users + WHERE username = $1 + LIMIT 1`, + [username] + ); + + console.log(`Found ${result.rows.length} users matching username: ${username}`); + + if (result.rows.length === 0) { + return null; + } + + const userData = result.rows[0]; + return formatUserData(userData); + } catch (error) { + // Safely log error without including circular references + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Error fetching user with username ${typeof username === 'string' ? username : '[invalid]'}: ${errorMessage}`); + return null; + } +} + +export async function getUserById(id: string) { + if (!id) { + console.error('User ID is required'); + return null; + } + + try { + const result = await query( + `SELECT + id, + username, + email, + website, + bio, + uuid, + avatar, + is_anon, + badges + FROM users + WHERE id = $1 + LIMIT 1`, + [id] + ); + + console.log(`Found ${result.rows.length} users matching ID: ${id}`); + + if (result.rows.length === 0) { + return null; + } + + const userData = result.rows[0]; + return formatUserData(userData); + } catch (error) { + console.error(`Error fetching user with ID ${id}: ${error instanceof Error ? error.message : 'Unknown error'}`); + return null; + } +} + +function formatUserData(userData: any) { + return { + id: userData.id, + username: userData.username, + email: userData.email || null, + website: userData.website || null, + bio: userData.bio || null, + uuid: userData.uuid, + avatar: userData.avatar, + isAnon: userData.is_anon || false, + badges: Array.isArray(userData.badges) ? userData.badges : [], + createdAt: userData.created_at, + }; +} \ No newline at end of file diff --git a/apps/api/src/rspc/router.ts b/apps/api/src/rspc/router.ts new file mode 100644 index 0000000..e564471 --- /dev/null +++ b/apps/api/src/rspc/router.ts @@ -0,0 +1,20 @@ +import { initTRPC } from '@trpc/server'; +import { createPost } from '../modules/posts/posts.service'; +import { createPostSchema } from '@/shared/schemas/post.schema'; + +const t = initTRPC.create(); + +export const router = t.router; +export const publicProcedure = t.procedure; + +export const appRouter = router({ + post: router({ + create: publicProcedure + .input(createPostSchema) + .mutation(async ({ input }) => { + return createPost(input); + }), + }), +}); + +export type AppRouter = typeof appRouter; \ No newline at end of file diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..0f399f9 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "lib": ["es2020"], + "outDir": "./dist", + "rootDir": "../..", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "baseUrl": "../..", + "paths": { + "@/*": ["apps/api/src/*"], + "@/shared/*": ["apps/shared/src/*"] + }, + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": [ + "src/**/*", + "../shared/src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/apps/client/.npmrc b/apps/client/.npmrc new file mode 100644 index 0000000..ed50fbb --- /dev/null +++ b/apps/client/.npmrc @@ -0,0 +1,5 @@ +# Skip optional dependencies completely +optional=false + +# Use legacy peer dependencies resolution +legacy-peer-deps=true \ No newline at end of file diff --git a/apps/client/Dockerfile b/apps/client/Dockerfile index 95721d2..3a64a50 100644 --- a/apps/client/Dockerfile +++ b/apps/client/Dockerfile @@ -1,15 +1,21 @@ -FROM nginx:alpine AS base -WORKDIR /usr/share/nginx/html +FROM node:20-alpine + +WORKDIR /workspace/apps/client + +COPY package*.json ./ + +# Install dependencies +RUN yarn install --network-timeout 600000 + +# Explicitly install the TanStack router plugin matching the router version +RUN yarn add -D @tanstack/router-plugin@1.106.0 -FROM oven/bun:latest AS builder -WORKDIR /app COPY . . -COPY .env .env -RUN bun i --frozen-lockfile -RUN bun vite build - -FROM base AS runtime -RUN rm -fr ./* -COPY --from=builder /app/dist ./ -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] + +# Create empty module file for Rollup native modules +RUN mkdir -p /workspace/apps/client/src/utils && \ + echo "export default {};" > /workspace/apps/client/src/utils/empty-module.js + +EXPOSE 5173 + +CMD ["yarn", "dev", "--", "--host", "0.0.0.0"] diff --git a/apps/client/package.json b/apps/client/package.json index bb5ac5f..2033a71 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -5,12 +5,12 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "ROLLUP_SKIP_NATIVES=true NODE_OPTIONS=--no-experimental-fetch vite build", "start": "vite preview", "tanstack-router": "tanstack-router generate" }, "engines": { - "node": ">=20" + "node": "20.x" }, "dependencies": { "@hazae41/option": "^1.1.4", @@ -52,7 +52,7 @@ }, "devDependencies": { "@tanstack/router-devtools": "^1.87.9", - "@tanstack/router-plugin": "^1.87.11", + "@tanstack/router-plugin": "^1.106.0", "@types/luxon": "^3.4.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -65,5 +65,8 @@ "typescript": "^5.5.3", "vite": "^5.4.1", "vite-tsconfig-paths": "^5.1.4" + }, + "resolutions": { + "rollup": "3.29.4" } } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 5748eb8..97af24f 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -1,5 +1,5 @@ -import type { router } from "lib/router"; -import { Providers } from "providers"; +import type { router } from "@/lib/router"; +import { Providers } from "@/providers"; // Register the router instance for type safety declare module "@tanstack/react-router" { diff --git a/apps/client/src/components/AuthWrapper.tsx b/apps/client/src/components/AuthWrapper.tsx index b695306..972fc10 100644 --- a/apps/client/src/components/AuthWrapper.tsx +++ b/apps/client/src/components/AuthWrapper.tsx @@ -1,17 +1,37 @@ -import { ReactNode } from "react"; +import { ReactNode, forwardRef } from "react"; import { useGlobalContext } from "@/contexts/GlobalContext"; - +import { cn } from "@/lib/utils"; interface AuthWrapperProps { children?: ReactNode; fallback?: ReactNode; + requireLogin?: boolean; + action?: () => Promise | void; + className?: string; } -export const AuthWrapper = ({ children, fallback }: AuthWrapperProps) => { - const { isLoggedIn } = useGlobalContext(); +export const AuthWrapper = forwardRef( + ({ children, fallback, requireLogin = false, className }, ref) => { + const { isLoggedIn, setShowLoginModal } = useGlobalContext(); + + if (!isLoggedIn && !requireLogin) { + return fallback ?? null; + } - if (!isLoggedIn) { - return fallback ?? null; - } + return ( +
{ + e.preventDefault(); + if (!isLoggedIn) { + setShowLoginModal(true); + } + }} + > + {children} +
+ ); + }, +); - return <>{children}; -}; +AuthWrapper.displayName = "AuthWrapper"; diff --git a/apps/client/src/components/Avatar.tsx b/apps/client/src/components/Avatar.tsx index 01dfc91..d2de7b8 100644 --- a/apps/client/src/components/Avatar.tsx +++ b/apps/client/src/components/Avatar.tsx @@ -2,6 +2,8 @@ import { classed } from "@tw-classed/react"; import type { FC } from "react"; import * as AvatarPrimitive from "@radix-ui/react-avatar"; import { cn } from "@/lib/utils"; +import { LucideIcon } from "lucide-react"; + const RandomBackgroundColors = [ "bg-red-600", "bg-blue-600", @@ -50,6 +52,7 @@ type AvatarProps = React.ComponentProps & { username?: string | null; hasRandomBackground?: boolean; className?: string; + icon?: LucideIcon; }; export const Avatar: FC = ({ @@ -57,8 +60,12 @@ export const Avatar: FC = ({ username, hasRandomBackground, className, + children = null, + icon, ...props }) => { + + const Icon = icon; const fallbackBackground = hasRandomBackground && username ? RandomBackgroundColors[ @@ -70,6 +77,7 @@ export const Avatar: FC = ({ + {Icon && } ); }; diff --git a/apps/client/src/components/Content.tsx b/apps/client/src/components/Content.tsx index 5172cd6..0103f04 100644 --- a/apps/client/src/components/Content.tsx +++ b/apps/client/src/components/Content.tsx @@ -77,7 +77,7 @@ export const Content: FC<{ content: string }> = ({ content }) => { ), p: ({ children, className }) => ( -

+

{children}

), @@ -95,7 +95,7 @@ export const Content: FC<{ content: string }> = ({ content }) => { ), div: ({ children, className }) => ( -
+
{children}
), diff --git a/apps/client/src/components/Icons.tsx b/apps/client/src/components/Icons.tsx new file mode 100644 index 0000000..916573c --- /dev/null +++ b/apps/client/src/components/Icons.tsx @@ -0,0 +1,19 @@ +export const Icons = { + Check: (props: any) => ( + + + + ), +}; diff --git a/apps/client/src/components/LeftSidebar.tsx b/apps/client/src/components/LeftSidebar.tsx index 592b2ee..82945ad 100644 --- a/apps/client/src/components/LeftSidebar.tsx +++ b/apps/client/src/components/LeftSidebar.tsx @@ -3,17 +3,24 @@ import { CreateGroup } from "@/components/CreateGroup"; import { Signout } from "@/components/Signout"; import { useAuth } from "@/hooks/useAuth"; import { useQuery } from "@/lib/rspc"; -import { Home as HomeIcon, LucideIcon, Users } from "lucide-react"; +import { + Home as HomeIcon, + LucideIcon, + SunIcon, + MoonIcon, + Users, +} from "lucide-react"; import { Button } from "@/components/ui/Button"; -import { MAIN_NAV_ITEMS } from "settings"; +import { MAIN_NAV_ITEMS } from "../settings"; import { cn } from "@/lib/utils"; import { Accordion } from "@/components/Accordion"; -import { membershipMocks } from "mocks/membershipMocks"; import { Avatar } from "@/components/Avatar"; import { Badge } from "@/components/ui/Badge"; -import { Switch } from "./inputs/Switch"; import { useGlobalContext } from "@/contexts/GlobalContext"; import { AuthWrapper } from "./AuthWrapper"; +import { Switch } from "./inputs/Switch"; +// import { communityMocks } from "@/shared/mocks/community.mocks"; + const renderNavItems = ( _items: (typeof MAIN_NAV_ITEMS)[keyof typeof MAIN_NAV_ITEMS], ) => @@ -35,26 +42,32 @@ const NavItem = ({ to, icon, badge, + onClick, }: { title: string; to: string; icon: LucideIcon; badge?: string; + onClick?: () => void; }) => { const Icon = icon; + const communityMocks = [] as any[]; return ( { + onClick?.(); + }} className={cn( "text-sm font-inter font-medium leading-5 text-base-muted-foreground cursor-pointer outline-none focus:outline-none focus:ring-0 focus:ring-offset-0", - "duration-200 hover:bg-muted hover:text-base-primary", + "duration-200 hover:bg-muted hover:text-base-primary hover:bg-base-muted", "flex items-center gap-2 rounded-md h-9 py-2 w-full p-2", )} >
- + {title}
{badge && ( @@ -75,12 +88,14 @@ const SidebarContent = () => { enabled: auth?.isSome(), }); + const communityMocks = [] as any[]; + return (