diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f726f02 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,39 @@ +# Pull Request Template + +## Description + +Please include a summary of the change and which issue is fixed. Also include relevant motivation and context. + +--- + +## Checklist +- [ ] I have tested my changes locally +- [ ] I have updated documentation as needed +- [ ] I have run `npx prisma generate` after schema changes +- [ ] I have run `npx prisma migrate dev` or `npx prisma migrate deploy` as appropriate + +--- + +## Post-Merge Steps for Maintainers + +**If this PR includes changes to the Prisma schema:** + +1. Run the following command to apply the migration to your database: + + ```sh + npx prisma migrate deploy + ``` + or, for local development: + ```sh + npx prisma migrate dev + ``` +2. Ensure your CI pipeline runs the migration before tests (add this step if missing): + ```yaml + - name: Run Prisma Migrate + run: npx prisma migrate deploy + ``` +3. Make sure the database user in CI has permission to run migrations. + +--- + +If you have any questions, please comment on this PR. diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..eab63b9 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,41 @@ +name: Contracts CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + contracts: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: contracts + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: Run tests + run: cargo test --workspace + + - name: Build WASM artifacts + run: cargo build --workspace --target wasm32-unknown-unknown --release + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3048941 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,75 @@ +name: Run Tests + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + test: + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/geev_test + JWT_SECRET: test-secret-key + NODE_ENV: test + runs-on: ubuntu-latest + + defaults: + run: + working-directory: app + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: geev_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Generate Prisma Client + run: | + npx prisma format + npx prisma generate + + - name: Run database migrations + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/geev_test + run: npx prisma migrate deploy + + - name: Run tests + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/geev_test + JWT_SECRET: test-secret-key + NODE_ENV: test + run: npm run test:ci + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + if: always() + with: + files: ./app/coverage/coverage-final.json + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 5ef6a52..63f2ea2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,12 @@ !.yarn/plugins !.yarn/releases !.yarn/versions +.pnpm +.turbo +yarn.lock +package-lock.json +pnpm-lock.yaml +wave-*.md # testing /coverage @@ -32,6 +38,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -39,3 +46,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/lib/generated/prisma + +# rust +**/target/ +wave-*.md diff --git a/README.md b/README.md index cde5191..e0de89c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Geev is a decentralized social platform built on the Stellar blockchain that enables users to create giveaways, post help requests, and participate in community-driven mutual aid. It combines social networking features with Web3 wallet integration to facilitate transparent, trustless giving and receiving. +[![Tests](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/geevapp) +[![Discord](https://img.shields.io/discord/1482790798016643103?style=for-the-badge&logo=discord&label=Join%20the%20community)](https://discord.gg/wQP2CkHj) + + ## FRONTEND TECHNOLOGY STACK - **Framework**: Next.js 15 (App Router) @@ -11,6 +15,23 @@ Geev is a decentralized social platform built on the Stellar blockchain that ena - **State Management**: React Context API + SWR for data fetching - **Icons**: Lucide React - **Animations**: Framer Motion (for scroll animations and transitions) +- **Theme System**: next-themes with light/dark mode support + +## DOCUMENTATION + +- [Theme System](docs/theme.md) - Light/dark mode implementation and usage guide +- [Components](docs/components.md) - Component library documentation + +## BACKEND INFRASTRUCTURE + +The backend is integrated into the Next.js application using API Routes. + +- **ORM**: Prisma (PostgreSQL) +- **API Routes**: Located in `app/api/` +- **Utilities**: + - `lib/prisma.ts`: Prisma client singleton and connection testing. + - `lib/api-response.ts`: Standardized API response helpers (`apiSuccess`, `apiError`). +- **Middleware**: Handles Request Logging and CORS in `middleware.ts`. ## RESOURCES @@ -19,14 +40,63 @@ Geev is a decentralized social platform built on the Stellar blockchain that ena - [PROJECT SUMMARY](https://docs.google.com/document/d/1ZEfrbVF_rjJ3GrLYeTxTboRL15dT0kaVyioXrdPpmMU) - [FEATURE SPECIFICATIONS](https://docs.google.com/document/d/1qRyFhhAqBgZU8NtrVmMk6HV2qSi0nb_K3sxrgPaKymI) + +## Logout Behavior + +The `/api/auth/logout` route is deprecated. + +Use: +- `signOut()` from next-auth/react (client-side) +- `POST /api/auth/signout` (server-side) + +This ensures proper session invalidation. + ## Getting Started -First, run the development server: +### Frontend Application + +If you're working on the frontend application, the `app` directory contains the Next.js codebase. To get started, follow these steps: + +1. Install dependencies: + ```bash + npm install + ``` +2. Configure environment variables in `.env`: + ```env + DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public" + ``` +3. Generate Prisma Client: + ```bash + npx prisma generate + ``` +4. Run the development server: + ```bash + npm run dev + ``` + Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing and creating pages and components. The backend API routes are located in `app/api/` and can be modified to implement the necessary functionality for the application. +Refer to the [app/README.md](app/README.md) for more detailed information on the frontend and backend infrastructure, documentation, and resources. -```bash -npm run dev -``` +### Smart Contracts -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +The `contracts` directory contains the Soroban smart contracts for the platform. To get started with the smart contracts, follow these steps: -You can start editing and creating pages and components. +1. Install Soroban CLI: + ```bash + cargo install --locked soroban-cli + ``` +2. Build the smart contracts: + ```bash + soroban build + ``` +3. Deploy the smart contracts to the Stellar testnet or a local Soroban environment: + ```bash + soroban deploy --network testnet + ``` + or + ```bash + soroban deploy --network local + ``` +4. Interact with the deployed smart contracts using the Soroban CLI or by integrating them into the frontend application. + Refer to the [contracts/README.md](contracts/README.md) for more detailed information on the smart contract architecture, deployment, and interaction. diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 0000000..fff74fb --- /dev/null +++ b/app/.env.example @@ -0,0 +1,23 @@ +DATABASE_URL=postgresql://geev:bridgelet_pass@localhost:5432/geev +AUTH_SECRET= +NEXTAUTH_SECRET= + +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=... +AWS_SECRET_ACCESS_KEY=... +AWS_S3_BUCKET=your-bucket-name +CDN_BASE_URL=https://cdn.yourdomain.com +# SEP-10 Stellar Web Authentication Configuration +# Generate a new Stellar keypair for the server using: https://laboratory.stellar.org/#account-creator?network=public +STELLAR_SERVER_SECRET=SBSW2XWCHXLQ4OZIQ2Q3TQ3DJC23LK3LQ3LQ3LQ3LQ3LQ3LQ3LQ3LQ3L +STELLAR_HOME_DOMAIN=geev.app +STELLAR_WEB_AUTH_DOMAIN=geev.app + +# Soroban Indexer Configuration +SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +GEEV_CONTRACT_ID= +SOROBAN_NETWORK=testnet +INDEXER_POLL_INTERVAL_MS=5000 + +# Cron Job Secret (for securing cron endpoints) +CRON_SECRET=your-secret-key-here diff --git a/app/.github/ISSUE_TEMPLATE/feature_request.md b/app/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..571f290 --- /dev/null +++ b/app/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,137 @@ +--- +name: Feature / Bug / Enhancement +about: Create a structured issue for Geev development +title: '' +labels: '' +assignees: '' +--- + +## Summary + + + +--- + +## Context + + + +--- + +## Problem Statement (if applicable) + + + +--- + +## Goal + + + +--- + +## Scope + + + +--- + +## Tasks + +- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 + + + +--- + +## Acceptance Criteria + +- [ ] Criteria 1 +- [ ] Criteria 2 +- [ ] Criteria 3 + + + +--- + +## Technical Notes / Constraints + + + +--- + +## Testing Requirements + + + +--- + +## Documentation + + + +--- + +## Labels + +Suggested labels: + +- `bug` / `enhancement` / `feature` +- `architecture` +- `good first issue` + +--- + +## Difficulty + + + +--- + +## Additional Notes + + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..dd8de6c --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,47 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +.turbo +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +yarn.lock +package-lock.json +pnpm-lock.yaml \ No newline at end of file diff --git a/app/.npmrc b/app/.npmrc new file mode 100644 index 0000000..495c645 --- /dev/null +++ b/app/.npmrc @@ -0,0 +1,2 @@ +public-hoist-pattern[]=next +public-hoist-pattern[]=next-auth \ No newline at end of file diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..c8a8799 --- /dev/null +++ b/app/README.md @@ -0,0 +1,262 @@ +## Notification System + +This app now includes a notification system for key user events: + +- Someone enters your giveaway (post owner notified) +- You win a giveaway (winner notified) *(to be enabled when winner selection logic is implemented)* +- Someone contributes to your help request *(to be enabled when help contribution logic is implemented)* +- A post you follow is closed *(to be enabled when post close logic is implemented)* +- You receive a new badge or rank up *(to be enabled when badge/rank logic is implemented)* + +**API Endpoints:** +- `GET /api/notifications` (paginated, filter by isRead) +- `PATCH /api/notifications/[id]/read` + +**Frontend:** +- Unread notification badge in Navbar +- `/notifications` page to view all notifications + +**How to add new triggers:** +Use the `createNotification` utility in `lib/notifications.ts` in any backend handler to send a notification for new events. + +**Migration:** +If you have DB access, run: +```bash +npx prisma migrate dev --name add_notification_model +``` +If you do not have DB access, a maintainer should run the above command after merging this PR. + +# Geev App (Next.js) + +Geev is a decentralized social platform built on the Stellar blockchain that enables users to create giveaways, post help requests, and participate in community-driven mutual aid. + +[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/geevapp) + +This package is the web application for Geev, built with Next.js, TypeScript, Prisma, and PostgreSQL. + +Use this guide to get a full local development environment running, including database migration and seed data. + +## Tech Stack + +- Next.js 16 (App Router) +- TypeScript +- Tailwind CSS v4 + shadcn/ui + Radix UI +- Auth.js (NextAuth) +- Prisma 7 + PostgreSQL + +## Prerequisites + +- Node.js 20+ +- pnpm 10+ +- PostgreSQL running locally + +## 1) Install Dependencies + +From the monorepo root (`new.app/`): + +```bash +pnpm install +``` + +## 2) Configure Environment Variables + +From `new.app/app/`, create `.env` (or copy `.env.example`): + +```bash +cp .env.example .env +``` + +Minimum required values: + +```env +DATABASE_URL=postgresql://geev:bridgelet_pass@localhost:5432/geev +AUTH_SECRET= +NEXTAUTH_SECRET= +``` + +Generate a strong secret (use the same value for both secrets if you want): + +```bash +openssl rand -base64 32 +``` + +## 3) Prepare the Database + +Run these commands from `new.app/app/`: + +```bash +pnpm prisma generate +pnpm prisma migrate deploy +pnpm prisma db seed +``` + +What seeding adds: + +- Default user ranks +- Default badges +- 5 dummy users for development login/testing + +## 4) Run the App + +From `new.app/app/`: + +```bash +pnpm dev +``` + +Open: + +- http://localhost:3000 + +## 5) Verify Dev Login + +In development mode, use the Dev User Switcher (bottom-right) to sign in as a seeded user. + +Expected behavior: + +- Navbar/sidebar update to authenticated state +- Main content redirects to `/feed` + +## Useful Commands + +From `new.app/app/`: + +```bash +pnpm dev +pnpm build +pnpm start +pnpm lint +pnpm test +pnpm test:watch +pnpm test:coverage +pnpm prisma studio +``` + +## Reset Local DB (Optional) + +If you want a clean local database and reseed everything: + +```bash +pnpm prisma migrate reset +``` + +This drops/recreates the schema, reapplies migrations, and runs seed. + +## Troubleshooting + +- `DATABASE_URL is required to run Prisma seed`: + - Ensure `.env` exists in `new.app/app/` and contains `DATABASE_URL`. +- Auth/session issues: + - Ensure `AUTH_SECRET` and `NEXTAUTH_SECRET` are set. +- Migration errors: + - Confirm PostgreSQL is running and the database in `DATABASE_URL` exists. + +## Project Docs + +- Theme system: `docs/theme.md` +- Components: `docs/components.md` + +## Resources + +- Figma UI Kit: https://www.figma.com/design/bx1z49rPLAXSsUSlQ03ElA/Geev-App?node-id=6-192&t=a3DcI1rqYjGvbhBd-0 +- App Prototype (Figma): https://www.figma.com/proto/bx1z49rPLAXSsUSlQ03ElA/Geev-App?node-id=6-192&t=Sk47E3cbSLVg2zcA-0&scaling=min-zoom&content-scaling=fixed&page-id=0%3A1&starting-point-node-id=6%3A192&show-proto-sidebar=1 +- Project Summary: https://docs.google.com/document/d/1ZEfrbVF_rjJ3GrLYeTxTboRL15dT0kaVyioXrdPpmMU +- Feature Specifications: https://docs.google.com/document/d/1qRyFhhAqBgZU8NtrVmMk6HV2qSi0nb_K3sxrgPaKymI + +## API Reference (Analytics) + +- **Endpoint:** `POST /api/analytics/events` +- **Purpose:** Track client and server events (page views, post lifecycle, interactions, errors). + +**Request Body** + +```json +{ + "eventType": "page_view", + "eventData": { "path": "/feed" }, + "pageUrl": "https://app.example.com/feed" +} +``` + +- `eventType` must be one of: + - `"page_view"` + - `"post_created"` + - `"entry_submitted"` + - `"like_added"` + - `"share_clicked"` + - `"error_occurred"` +- `eventData` is optional JSON metadata (non-PII only). +- `pageUrl` is optional; when omitted, the client helper populates it from `window.location.href`. + +**Headers** + +- `x-user-id` (optional) – the authenticated user ID for DAU/attribution. The default client helper will set this when a user is available. + +**Response** + +```json +{ + "success": true, + "data": { "tracked": true } +} +``` + +Analytics failures never block product flows; on internal errors the endpoint returns `{"tracked": false}` but still responds with `success: true`. + +### Metrics API + +- **Endpoint:** `GET /api/analytics/metrics` +- **Purpose:** Fetch high-level platform metrics over a time window. + +**Query Params** + +- `period` (optional): + - `"24h"` – last 24 hours + - `"7d"` – last 7 days (default) + - `"30d"` – last 30 days + +**Response** + +```json +{ + "success": true, + "data": { + "period": "7d", + "metrics": { + "active_users": 12, + "posts_created": 5, + "entries_submitted": 42, + "page_views": 380 + } + } +} +``` + +- `active_users` – distinct users with at least one tracked event in the period. +- `posts_created` – posts created in the period. +- `entries_submitted` – number of `entry_submitted` events in the period. +- `page_views` – number of `page_view` events in the period. + +Results are cached in-memory for 5 minutes per `period` value to reduce load. + +### Client Tracking Helper + +A lightweight helper exists at `lib/analytics.ts`: + +```ts +import { trackEvent } from '@/lib/analytics'; + +await trackEvent('page_view', { path: '/feed' }); +await trackEvent('post_created', { postId: 'post_123' }, { userId: '1' }); +``` + +Signature: + +- `trackEvent(eventType: string, eventData?: Record, options?: { userId?: string })` +- No-ops on the server, silently swallows network errors on the client. + +Privacy guarantees: + +- No PII is added on the server; `eventData` should not include emails, wallet secrets, or other sensitive values. +- Anonymous events are supported (no `x-user-id`). +- Events are used for behavioral and performance insights, not for tracking individual identities beyond an opaque user ID. diff --git a/app/TODO.md b/app/TODO.md new file mode 100644 index 0000000..5d037e4 --- /dev/null +++ b/app/TODO.md @@ -0,0 +1,42 @@ +# API Integration for Modals - TODO + +## Approved Plan Summary +- Remove fake createPost/submitEntry/makeContribution from app-context.tsx +- Add refreshPosts() and refreshEntries(postId) to context +- Modals: Replace context fake calls → real API POST + refresh on success +- Contributions: Keep local-only (no API endpoint yet) +- Use direct fetch() (no new helpers needed) + +## Step-by-Step Implementation + + + - Remove `createPost`, `submitEntry`, `makeContribution` functions + - Update contextValue to expose refresh fns + - Keep dispatch actions (ADD_ENTRY etc. not needed anymore) + +### 2. Update CreateGiveawayModal ✅ + - handleSubmit: fetch(POST /api/posts, {formData}) + - Success: context.refreshPosts() + toast + reset + close + - Remove `const { createPost } = useAppContext()` + +### 3. Update CreateRequestModal ✅ + - Same as above for help-requests + +### 4. Update EntryForm ✅ + - handleSubmit: fetch(POST /api/posts/${post.id}/entries) + - Success: context.refreshEntries(post.id) + context.refreshPosts() + +### 5. Update ContributionForm ✅ + - Keep existing `makeContribution` call (local only for now) + +### 6. Test + - Open app, test all modals + - Verify data persists on refresh + - Check API routes work + +### 7. Cleanup + - Remove unused context actions if any + - attempt_completion + +**Next: Step 1 - Context updates** + diff --git a/app/app/(auth)/login/page.tsx b/app/app/(auth)/login/page.tsx new file mode 100644 index 0000000..f3e76fe --- /dev/null +++ b/app/app/(auth)/login/page.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { WalletLoginForm } from '@/components/wallet-login-form'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; + +export default function LoginPage() { + const router = useRouter(); + const { status } = useSession(); + + useEffect(() => { + if (status === 'authenticated') { + router.replace('/feed'); + } + }, [status, router]); + + if (status === 'authenticated') { + return null; + } + + return ( +
+
+ {/* Header */} +
+ + + Back to home + +
+ Geev + Geev + {/* Geev */} +
+

Welcome back

+

+ Connect your wallet to continue +

+
+ + + + Sign in + + Connect your wallet to access your account + + + + + +
+ + Don't have an account?{' '} + + + Sign up + +
+
+
+
+
+ ); +} diff --git a/app/app/(auth)/login/route.ts b/app/app/(auth)/login/route.ts new file mode 100644 index 0000000..24b0ed2 --- /dev/null +++ b/app/app/(auth)/login/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { createToken } from "@/lib/jwt"; +import { z } from "zod"; + +const loginSchema = z.object({ + walletAddress: z.string().min(1, "Wallet address is required"), + signature: z.string().min(1, "Signature is required"), + message: z.string().min(1, "Message is required"), +}); + +// Mock wallet signature verification - replace with actual implementation +async function verifyWalletSignature( + walletAddress: string, + signature: string, + message: string +): Promise { + // In production, verify the signature using the wallet's public key + // This is a mock implementation for demonstration + console.log(`Verifying signature for wallet: ${walletAddress}`); + return signature.length > 10; // Simple validation for demo +} + +/** + * Handles user login with wallet signature. + * + * @deprecated This endpoint is legacy. For new implementations, use Auth.js + * or the Auth.js signin endpoint directly (POST /api/auth/signin). + * + * @param request - The incoming Request object + * @returns A NextResponse confirming successful login and setting session cookie + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + const parsed = loginSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request data", details: parsed.error.issues }, + { status: 400 } + ); + } + + const { walletAddress, signature, message } = parsed.data; + + // Verify wallet signature + const isValidSignature = await verifyWalletSignature(walletAddress, signature, message); + + if (!isValidSignature) { + return NextResponse.json( + { error: "Invalid wallet signature" }, + { status: 401 } + ); + } + + // Find user by wallet address + const user = await prisma.user.findUnique({ + where: { walletAddress }, + }); + + if (!user) { + return NextResponse.json( + { error: "User not found. Please register first." }, + { status: 404 } + ); + } + + // Create JWT token + const token = await createToken({ + userId: user.id, + walletAddress: user.walletAddress, + username: user.name, + }); + + // Create response with cookie + const response = NextResponse.json({ + success: true, + user: { + id: user.id, + walletAddress: user.walletAddress, + username: user.name, + email: null, + avatar: user.avatarUrl, + bio: user.bio, + joinDate: user.createdAt, + }, + token, // Also return token in response body for client storage + }, { + headers: { + // RFC 299: Miscellaneous persistent warning + "Warning": '299 - "Deprecated: This endpoint is legacy. Use Auth.js signIn instead."', + }, + }); + + // Set secure cookie + response.cookies.set("auth-token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 30 * 24 * 60 * 60, // 30 days + path: "/", + }); + + return response; + } catch (error) { + console.error("Login error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/app/(auth)/logout/route.ts b/app/app/(auth)/logout/route.ts new file mode 100644 index 0000000..3e37abb --- /dev/null +++ b/app/app/(auth)/logout/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; + +/** + * Handles user logout by clearing all authentication cookies. + * + * @deprecated This endpoint is legacy. For new implementations, use Auth.js `signOut()` + * or the Auth.js signout endpoint directly (POST /api/auth/signout). + * + * @param request - The incoming NextRequest object + * @returns A NextResponse confirming successful logout and clearing session cookies + */ +export async function POST(request: Request) { + try { + // Create response to confirm logout + const response = NextResponse.json( + { + success: true, + message: "Successfully logged out", + }, + { + headers: { + // RFC 299: Miscellaneous persistent warning + "Warning": '299 - "Deprecated: This endpoint is legacy. Use Auth.js signOut instead."', + }, + } + ); + + const isProduction = process.env.NODE_ENV === "production"; + + // Cookie options for secure clearing + const cookieOptions = { + httpOnly: true, + secure: isProduction, + sameSite: "lax" as const, + maxAge: 0, // Expire immediately + path: "/", + expires: new Date(0), // Set to epoch to ensure expiration + }; + + // 1. Clear the legacy auth-token + response.cookies.set("auth-token", "", cookieOptions); + + // 2. Clear Auth.js session token (standard) + response.cookies.set("next-auth.session-token", "", cookieOptions); + + // 3. Clear Auth.js session token (secure version used in production/HTTPS) + response.cookies.set("__Secure-next-auth.session-token", "", cookieOptions); + + return response; + } catch (error) { + console.error("Logout error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/app/(auth)/me/route.ts b/app/app/(auth)/me/route.ts new file mode 100644 index 0000000..e2636ad --- /dev/null +++ b/app/app/(auth)/me/route.ts @@ -0,0 +1,80 @@ +import { apiError, apiSuccess } from '@/lib/api-response'; + +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +/** + * Fetches the current user profile. + * + * @deprecated This endpoint is legacy. For new implementations, use Auth.js `auth()` + * directly in server components or the Auth.js session hooks in client components. + * + * @returns A Response with user profile data + */ +export async function GET () { + try { + const session = await auth(); + + if (!session?.user?.id) { + return apiError('Unauthorized', 401); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + include: { + badges: { + include: { + badge: true, + }, + }, + rank: true, + _count: { + select: { + posts: true, + entries: true, + comments: true, + interactions: true, + badges: true, + analyticsEvents: true, + followings: true, + followers: true, + helpContributions: true, + accounts: true, + sessions: true, + }, + }, + }, + }); + + if (!user) { + return apiError('User not found', 404); + } + + const normalizedUser = { + ...user, + rank: + user.rank ?? + ({ + id: 'newcomer', + level: 1, + title: 'Newcomer', + color: 'text-gray-500', + minPoints: 0, + maxPoints: 199, + } as const), + badges: user.badges.map((userBadge) => ({ + ...userBadge.badge, + awardedAt: userBadge.awardedAt, + })), + }; + + const response = apiSuccess(normalizedUser, 'OK', 200); + + // Add RFC 299 deprecation warning + response.headers.set("Warning", '299 - "Deprecated: This endpoint is legacy. Use Auth.js instead."'); + + return response; + } catch (error) { + return apiError('Failed to fetch current user', 500); + } +} diff --git a/app/app/(auth)/register/page.tsx b/app/app/(auth)/register/page.tsx new file mode 100644 index 0000000..a8ae990 --- /dev/null +++ b/app/app/(auth)/register/page.tsx @@ -0,0 +1,72 @@ +'use server'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { WalletLoginForm } from '@/components/wallet-login-form'; + +export default async function RegisterPage() { + return ( +
+
+ {/* Header */} +
+ + + Back to home + +
+ Geev + Geev + {/* Geev */} +
+

Create your account

+

+ Join the community and start giving back +

+
+ + + + Sign up + + Create your account to get started + + + + +
+ + Already have an account?{' '} + + + Sign in + +
+
+
+
+
+ ); +} diff --git a/app/app/(auth)/register/route.ts b/app/app/(auth)/register/route.ts new file mode 100644 index 0000000..e0fcdd2 --- /dev/null +++ b/app/app/(auth)/register/route.ts @@ -0,0 +1,137 @@ +import { NextResponse } from "next/server"; +import { createToken } from "@/lib/jwt"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +const registerSchema = z.object({ + walletAddress: z.string().min(1, "Wallet address is required"), + signature: z.string().min(1, "Signature is required"), + message: z.string().min(1, "Message is required"), + username: z.string().min(3, "Username must be at least 3 characters").max(30, "Username too long"), + email: z.string().email("Invalid email").optional(), +}); + +// Mock wallet signature verification - replace with actual implementation +async function verifyWalletSignature ( + walletAddress: string, + signature: string, + message: string +): Promise { + // In production, verify the signature using the wallet's public key + console.log(`Verifying signature for wallet: ${walletAddress}`); + return signature.length > 10; // Simple validation for demo +} + +/** + * Handles user registration with wallet signature. + * + * @deprecated This endpoint is legacy. For new implementations, use Auth.js + * or the Auth.js signin endpoint directly (POST /api/auth/signin). + * + * @param request - The incoming Request object + * @returns A NextResponse confirming successful registration and setting session cookie + */ +export async function POST (request: Request) { + try { + const body = await request.json(); + const parsed = registerSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request data", details: parsed.error.issues }, + { status: 400 } + ); + } + + const { walletAddress, signature, message, username, email } = parsed.data; + + // Verify wallet signature + const isValidSignature = await verifyWalletSignature(walletAddress, signature, message); + + if (!isValidSignature) { + return NextResponse.json( + { error: "Invalid wallet signature" }, + { status: 401 } + ); + } + + // Check if user already exists + const existingUser = await prisma.user.findUnique({ + where: { walletAddress }, + }); + + if (existingUser) { + return NextResponse.json( + { error: "User with this wallet address already exists" }, + { status: 409 } + ); + } + + // Check if username is taken + const existingUsers = await prisma.user.findMany({ + where: { name: username }, + }); + + if (existingUsers.length > 0) { + return NextResponse.json( + { error: "Username already taken" }, + { status: 409 } + ); + } + + // Create new user + const user = await prisma.user.create({ + data: { + walletAddress, + name: username, + bio: null, + avatarUrl: `https://api.dicebear.com/7.x/identicon/svg?seed=${walletAddress}`, + xp: 0, + }, + }); + + // Create JWT token + const token = await createToken({ + userId: user.id, + walletAddress: user.walletAddress, + username: user.name, + }); + + // Create response with cookie + const response = NextResponse.json({ + success: true, + user: { + id: user.id, + walletAddress: user.walletAddress, + username: user.name, + email: null, + avatar: user.avatarUrl, + bio: user.bio, + joinDate: user.createdAt, + }, + token, + }, { + headers: { + // RFC 299: Miscellaneous persistent warning + "Warning": '299 - "Deprecated: This endpoint is legacy. Use Auth.js signIn instead."', + }, + }); + + // Set secure cookie + response.cookies.set("auth-token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 30 * 24 * 60 * 60, // 30 days + path: "/", + }); + + return response; + } catch (error) { + console.error("Registration error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/app/(auth)/session/route.ts b/app/app/(auth)/session/route.ts new file mode 100644 index 0000000..13eeb1f --- /dev/null +++ b/app/app/(auth)/session/route.ts @@ -0,0 +1,77 @@ +import { getTokenFromRequest, verifyToken } from "@/lib/jwt"; + +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +/** + * Checks for an active session. + * + * @deprecated This endpoint is legacy. For new implementations, use Auth.js `auth()` + * or `useSession()` instead. + * + * @param request - The incoming Request object + * @returns A NextResponse with session data + */ +export async function GET (request: Request) { + try { + // Get token from cookies + const token = getTokenFromRequest(request); + + if (!token) { + return NextResponse.json( + { error: "No authentication token found" }, + { status: 401 } + ); + } + + // Verify and decode token + const payload = await verifyToken(token); + console.log("Session payload:", payload); + // Find user in database + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + }); + + if (!user) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + // Return session data + return NextResponse.json({ + success: true, + user: { + id: user.id, + walletAddress: user.walletAddress, + username: user.name, + email: null, + avatar: user.avatarUrl, + bio: user.bio, + joinDate: user.createdAt, + }, + token: { + expiresAt: new Date(payload.exp * 1000).toISOString(), + }, + }, { + headers: { + // RFC 299: Miscellaneous persistent warning + "Warning": '299 - "Deprecated: This endpoint is legacy. Use Auth.js session instead."', + }, + }); + } catch (error: any) { + if (error.message === "Invalid token") { + return NextResponse.json( + { error: "Invalid or expired token" }, + { status: 401 } + ); + } + + console.error("Session check error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/app/activity/page.tsx b/app/app/activity/page.tsx new file mode 100644 index 0000000..25c3068 --- /dev/null +++ b/app/app/activity/page.tsx @@ -0,0 +1,350 @@ +'use client'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + Calendar, + DollarSign, + Gift, + Heart, + TrendingUp, + Trophy, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +import { AuthGuard } from '@/components/auth-guard'; +import { Badge } from '@/components/ui/badge'; +import Link from 'next/link'; +import { UserRankBadge } from '@/components/user-rank-badge'; +import { useAppContext } from '@/contexts/app-context'; +import { useEffect, useState } from 'react'; + +export default function ActivityPage() { + const { user } = useAppContext(); + const [activity, setActivity] = useState(null); + const [userPosts, setUserPosts] = useState([]); + const [userEntries, setUserEntries] = useState([]); + const [userContributions, setUserContributions] = useState([]); + const [recentActivities, setRecentActivities] = useState([]); + + useEffect(() => { + if (!user?.id) return; + + const loadActivity = async () => { + try{ + const [activityRes, postsRes] = await Promise.all([ + fetch(`/api/users/${user.id}/activity`), + fetch(`/api/posts`), + ]); + + if (activityRes.ok) { + const activityData = await activityRes.json(); + const data = activityData.data ?? {}; + setUserPosts(data.posts ?? []); + setUserEntries(data.entries ?? []); + setUserContributions(data.contributions ?? []); + setRecentActivities(data.recentActivities ?? []); + } + } catch (error) { + console.error('Failed to load activity:', error); + } + }; + loadActivity(); + }, [user?.id]); + + + + const ActivityItem = ({ + activity, + }: { + activity: (typeof recentActivities)[0]; + }) => ( + + +
+ + + + + {activity.user.name + .split(' ') + .map((n) => n[0]) + .join('')} + + + + +
+
+ + {activity.user.name} + + +
+ +
+ {activity.type === 'post' && ( + <> + {activity.post?.type === 'giveaway' ? ( + + ) : ( + + )} + + Created a{' '} + {activity.post?.type === 'giveaway' + ? 'giveaway' + : 'help request'} + :{' '} + {activity.post?.title} + + + )} + + {activity.type === 'entry' && ( + <> + + + Entered giveaway:{' '} + {activity.post?.title} + + + )} + + {activity.type === 'contribution' && ( + <> + + + Contributed ${activity.contribution?.amount.toFixed(2)} to:{' '} + {activity.post?.title} + + + )} +
+ + + + {activity.timestamp.toLocaleDateString()} at{' '} + {activity.timestamp.toLocaleTimeString()} + +
+
+
+
+ ); + + return ( + +
+
+ +

Activity

+
+ + + + Recent Activity + + My Posts ({userPosts.length}) + + + My Entries ({userEntries.length}) + + + My Contributions ({userContributions.length}) + + + + + + + Community Activity + + + {recentActivities.length > 0 ? ( + recentActivities + .slice(0, 20) + .map((activity) => ( + + )) + ) : ( +
+ +

+ No activity yet +

+

+ Be the first to create some activity! +

+
+ )} +
+
+
+ + + + + Your Posts + + + {userPosts.length > 0 ? ( + userPosts.map((post) => ( + + +
+
+ {post.type === 'giveaway' ? ( + + ) : ( + + )} +
+

{post.title}

+

+ {post.type === 'giveaway' + ? 'Giveaway' + : 'Help Request'}{' '} + • {post.createdAt.toLocaleDateString()} +

+
+
+ + {post.status} + +
+
+
+ )) + ) : ( +
+ +

No posts yet

+

+ Create your first giveaway or help request! +

+
+ )} +
+
+
+ + + + + Your Entries + + + {userEntries.length > 0 ? ( + userEntries.map((entry) => { + const post = entry.post; + return ( + + +
+
+ +
+

{post?.title}

+

+ Entered{' '} + {entry.submittedAt.toLocaleDateString()} +

+
+
+ {entry.isWinner && ( + + Winner! + + )} +
+
+
+ ); + }) + ) : ( +
+ +

+ No entries yet +

+

+ Enter some giveaways to see them here! +

+
+ )} +
+
+
+ + + + + Your Contributions + + + {userContributions.length > 0 ? ( + userContributions.map((contribution) => { + const post = contribution.post; + return ( + + +
+
+ +
+

{post?.title}

+

+ Contributed{' '} + {contribution.contributedAt.toLocaleDateString()} +

+
+
+ + ${contribution.amount.toFixed(2)} + +
+
+
+ ); + }) + ) : ( +
+ +

+ No contributions yet +

+

+ Help others by contributing to their requests! +

+
+ )} +
+
+
+
+
+
+ ); +} diff --git a/app/app/api/analytics/events/route.ts b/app/app/api/analytics/events/route.ts new file mode 100644 index 0000000..4470430 --- /dev/null +++ b/app/app/api/analytics/events/route.ts @@ -0,0 +1,59 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { readJsonBody } from "@/lib/parse-json-body"; +import { auth } from "@/lib/auth"; + +const VALID_EVENTS = [ + "page_view", + "post_created", + "entry_submitted", + "like_added", + "share_clicked", + "error_occurred", +] as const; + +export async function POST(request: NextRequest) { + try { + const parsed = await readJsonBody>(request); + if (!parsed.ok) return parsed.response; + + const body = parsed.data ?? {}; + const { eventType, eventData, pageUrl } = body as { + eventType?: unknown; + eventData?: unknown; + pageUrl?: unknown; + }; + + if (typeof eventType !== "string" || !VALID_EVENTS.includes(eventType as any)) { + return apiError("Invalid event type", 400); + } + + // Basic guard against excessively large payloads + const serialized = JSON.stringify(eventData ?? {}); + if (serialized.length > 10_000) { + return apiError("Event data too large", 400); + } + + const session = await auth(); + const userId = session?.user?.id ?? null; + + const userAgent = request.headers.get("user-agent"); + + await prisma.analyticsEvent.create({ + data: { + userId, + eventType, + eventData: eventData ?? {}, + pageUrl: pageUrl ?? null, + userAgent: userAgent ?? null, + }, + } as any); + + return apiSuccess({ tracked: true }); + } catch (error) { + console.error("Analytics tracking failed:", error); + // Do not break user flows because of analytics + return apiSuccess({ tracked: false }); + } +} \ No newline at end of file diff --git a/app/app/api/analytics/metrics/route.ts b/app/app/api/analytics/metrics/route.ts new file mode 100644 index 0000000..09910cf --- /dev/null +++ b/app/app/api/analytics/metrics/route.ts @@ -0,0 +1,106 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { unstable_cache } from "next/cache"; + +interface MetricsPayload { + period: string; + metrics: { + active_users: number; + posts_created: number; + entries_submitted: number; + page_views: number; + }; +} + +const CACHE_TTL_SECONDS = 5 * 60; // 5 minutes +const ALLOWED_PERIODS = ["24h", "7d", "30d"] as const; +type AllowedPeriod = (typeof ALLOWED_PERIODS)[number]; + +function getDateFromPeriod(period: string): Date { + const now = Date.now(); + switch (period) { + case "24h": + return new Date(now - 24 * 60 * 60 * 1000); + case "30d": + return new Date(now - 30 * 24 * 60 * 60 * 1000); + case "7d": + default: + return new Date(now - 7 * 24 * 60 * 60 * 1000); + } +} + +/** + * Fetches analytics metrics from the database. + * Wrapped in unstable_cache for persistent caching across restarts and instances. + */ +const getCachedMetrics = unstable_cache( + async (period: AllowedPeriod): Promise => { + const dateFrom = getDateFromPeriod(period); + + const [activeUsersData, postsCreated, entriesSubmitted, pageViews] = + await Promise.all([ + prisma.analyticsEvent.findMany({ + where: { + createdAt: { gte: dateFrom }, + userId: { not: null }, + }, + select: { userId: true }, + distinct: ["userId"], + } as any), + prisma.post.count({ + where: { createdAt: { gte: dateFrom } }, + } as any), + prisma.analyticsEvent.count({ + where: { + eventType: "entry_submitted", + createdAt: { gte: dateFrom }, + }, + } as any), + prisma.analyticsEvent.count({ + where: { + eventType: "page_view", + createdAt: { gte: dateFrom }, + }, + } as any), + ]); + + return { + period, + metrics: { + active_users: activeUsersData.length, + posts_created: postsCreated, + entries_submitted: entriesSubmitted, + page_views: pageViews, + }, + }; + }, + ["analytics-metrics"], + { + revalidate: CACHE_TTL_SECONDS, + tags: ["analytics"], + } +); + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const periodParam = searchParams.get("period") || "7d"; + + // Validate period to prevent unbounded cache growth and invalid queries + if (!ALLOWED_PERIODS.includes(periodParam as AllowedPeriod)) { + return apiError( + `Invalid period. Allowed values: ${ALLOWED_PERIODS.join(", ")}`, + 400 + ); + } + + const period = periodParam as AllowedPeriod; + const payload = await getCachedMetrics(period); + + return apiSuccess(payload); + } catch (error) { + console.error("Analytics metrics error:", error); + return apiError("Failed to fetch metrics", 500); + } +} \ No newline at end of file diff --git a/app/app/api/analytics/test.ts b/app/app/api/analytics/test.ts new file mode 100644 index 0000000..5b6d3c2 --- /dev/null +++ b/app/app/api/analytics/test.ts @@ -0,0 +1,142 @@ +/** + * Test file for the analytics API endpoints + * Run this with: npx tsx app/api/analytics/test.ts + */ + +import { POST as postEvent } from "./events/route"; +import { GET as getMetrics } from "./metrics/route"; + +async function testAnalyticsAPI() { + console.log("========================================"); + console.log(" Testing ANALYTICS API endpoints "); + console.log("========================================\n"); + + const EVENTS_URL = "http://localhost:3000/api/analytics/events"; + const METRICS_URL = "http://localhost:3000/api/analytics/metrics"; + + // 1) Test basic valid event tracking + console.log("[1] POST /api/analytics/events → valid page_view event"); + const validEventReq = new Request(EVENTS_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-user-id": "user-1", + "user-agent": "AnalyticsTest/1.0", + }, + body: JSON.stringify({ + eventType: "page_view", + eventData: { path: "/feed" }, + pageUrl: "http://localhost:3000/feed", + }), + }); + + const validEventRes = await postEvent(validEventReq as any); + const validEventJson = await validEventRes.json(); + + console.log(" Status :", validEventRes.status); + console.log(" Success :", validEventJson.success); + console.log(" Tracked :", validEventJson.data?.tracked ?? "—"); + console.log("----------------------------------------\n"); + + // 2) Test invalid event type handling + console.log("[2] POST /api/analytics/events → invalid event type"); + const invalidEventReq = new Request(EVENTS_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + eventType: "not_a_real_event", + eventData: {}, + }), + }); + + const invalidEventRes = await postEvent(invalidEventReq as any); + const invalidEventJson = await invalidEventRes.json(); + + console.log(" Status :", invalidEventRes.status); + console.log(" Success :", invalidEventJson.success); + console.log(" Error :", invalidEventJson.error ?? "—"); + console.log("----------------------------------------\n"); + + // 3) Seed additional events for metrics (two users, multiple types) + console.log("[3] POST /api/analytics/events → seed events for metrics"); + + const seedEvents = [ + { + userId: "user-1", + eventType: "page_view", + eventData: { path: "/leaderboard" }, + }, + { + userId: "user-2", + eventType: "page_view", + eventData: { path: "/feed" }, + }, + { + userId: "user-2", + eventType: "entry_submitted", + eventData: { postId: "post_a1" }, + }, + { + userId: "user-1", + eventType: "error_occurred", + eventData: { source: "analytics-test", message: "Simulated error" }, + }, + ] as const; + + for (const evt of seedEvents) { + const req = new Request(EVENTS_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-user-id": evt.userId, + }, + body: JSON.stringify({ + eventType: evt.eventType, + eventData: evt.eventData, + pageUrl: "http://localhost:3000" + ((evt.eventData as any)?.path || "/"), + }), + }); + const res = await postEvent(req as any); + const json = await res.json(); + console.log( + ` Seeded ${evt.eventType} for ${evt.userId} → status ${res.status}, tracked=${json.data?.tracked}`, + ); + } + console.log("----------------------------------------\n"); + + // 4) Fetch metrics for last 24h + console.log("[4] GET /api/analytics/metrics → period=24h"); + const metricsReq = new Request(`${METRICS_URL}?period=24h`); + const metricsRes = await getMetrics(metricsReq as any); + const metricsJson = await metricsRes.json(); + + console.log(" Status :", metricsRes.status); + console.log(" Success :", metricsJson.success); + console.log(" Period :", metricsJson.data?.period ?? "—"); + console.log(" Metrics :", JSON.stringify(metricsJson.data?.metrics ?? {}, null, 2)); + console.log("----------------------------------------\n"); + + // 5) Call metrics again to exercise cache + console.log("[5] GET /api/analytics/metrics → cached (24h)"); + const metricsReqCached = new Request(`${METRICS_URL}?period=24h`); + const metricsResCached = await getMetrics(metricsReqCached as any); + const metricsJsonCached = await metricsResCached.json(); + + console.log(" Status :", metricsResCached.status); + console.log(" Success :", metricsJsonCached.success); + console.log(" Period :", metricsJsonCached.data?.period ?? "—"); + console.log(" Metrics :", JSON.stringify(metricsJsonCached.data?.metrics ?? {}, null, 2)); + console.log("----------------------------------------\n"); + + console.log("\n"); + console.log("===================================="); + console.log(" Analytics tests completed "); + console.log("===================================="); +} + +if (require.main === module) { + testAnalyticsAPI().catch((err) => { + console.error("\nAnalytics test suite failed:", err); + process.exit(1); + }); +} diff --git a/app/app/api/auth/[...nextauth]/route.ts b/app/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c55a45e --- /dev/null +++ b/app/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth"; + +export const { GET, POST } = handlers; diff --git a/app/app/api/auth/challenge/route.ts b/app/app/api/auth/challenge/route.ts new file mode 100644 index 0000000..60a6ed9 --- /dev/null +++ b/app/app/api/auth/challenge/route.ts @@ -0,0 +1,92 @@ +/** + * SEP-10 Challenge Endpoint + * + * GET /api/auth/challenge?publicKey= + * + * Generates a Stellar transaction (XDR) challenge for the client to sign. + * This implements the first step of SEP-10 Web Authentication. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md + */ + +import { NextRequest, NextResponse } from "next/server"; +import { generateChallenge } from "@/lib/sep10"; +import { z } from "zod"; + +// Validation schema for the request +const challengeQuerySchema = z.object({ + publicKey: z.string().min(56).max(56), +}); + +/** + * GET handler for generating SEP-10 challenges + */ +export async function GET(request: NextRequest): Promise { + try { + // Parse and validate query parameters + const { searchParams } = new URL(request.url); + const publicKey = searchParams.get("publicKey"); + + const validationResult = challengeQuerySchema.safeParse({ publicKey }); + + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid request", + message: "Invalid or missing publicKey parameter. Must be a valid Stellar public key (56 characters).", + details: validationResult.error.errors, + }, + { status: 400 } + ); + } + + const { publicKey: clientPublicKey } = validationResult.data; + + // Generate the challenge transaction + const challenge = generateChallenge(clientPublicKey); + + // Return the challenge to the client + return NextResponse.json( + { + transaction: challenge.transactionXDR, + network_passphrase: "Public Global Stellar Network ; September 2015", + // Include additional metadata for client convenience + expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(), // 15 minutes + }, + { + status: 200, + headers: { + // Prevent caching of challenges + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + } + ); + } catch (error) { + console.error("Error generating challenge:", error); + + return NextResponse.json( + { + error: "Internal server error", + message: error instanceof Error ? error.message : "Failed to generate challenge", + }, + { status: 500 } + ); + } +} + +/** + * OPTIONS handler for CORS preflight requests + */ +export async function OPTIONS(): Promise { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }, + }); +} diff --git a/app/app/api/auth/me/route.ts b/app/app/api/auth/me/route.ts new file mode 100644 index 0000000..1f38932 --- /dev/null +++ b/app/app/api/auth/me/route.ts @@ -0,0 +1,67 @@ +import { apiError, apiSuccess } from '@/lib/api-response'; + +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export async function GET () { + try { + const session = await auth(); + + if (!session?.user?.id) { + return apiError('Unauthorized', 401); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + include: { + badges: { + include: { + badge: true, + }, + }, + rank: true, + _count: { + select: { + posts: true, + entries: true, + comments: true, + interactions: true, + badges: true, + analyticsEvents: true, + followings: true, + followers: true, + helpContributions: true, + accounts: true, + sessions: true, + }, + }, + }, + }); + + if (!user) { + return apiError('User not found', 404); + } + + const normalizedUser = { + ...user, + rank: + user.rank ?? + ({ + id: 'newcomer', + level: 1, + title: 'Newcomer', + color: 'text-gray-500', + minPoints: 0, + maxPoints: 199, + } as const), + badges: user.badges.map((userBadge) => ({ + ...userBadge.badge, + awardedAt: userBadge.awardedAt, + })), + }; + + return apiSuccess(normalizedUser, 'OK', 200); + } catch (error) { + return apiError('Failed to fetch current user', 500); + } +} diff --git a/app/app/api/auth/nonce/route.ts b/app/app/api/auth/nonce/route.ts new file mode 100644 index 0000000..0ff88c9 --- /dev/null +++ b/app/app/api/auth/nonce/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import crypto from "crypto"; + +/** + * GET /api/auth/nonce + * + * Generates a unique nonce for the client to use in the login/register signature. + * This ensures that signatures are one-time use and server-issued. + */ +export async function GET() { + try { + const nonce = crypto.randomBytes(32).toString("hex"); + + // Nonce expires in 15 minutes + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + // Save nonce to the database + await prisma.authNonce.create({ + data: { + nonce, + expiresAt, + used: false, + }, + }); + + return NextResponse.json({ nonce }, { status: 200 }); + } catch (error) { + console.error("Error generating nonce:", error); + return NextResponse.json( + { error: "Internal server error", message: "Failed to generate nonce" }, + { status: 500 } + ); + } +} diff --git a/app/app/api/auth/verify/route.ts b/app/app/api/auth/verify/route.ts new file mode 100644 index 0000000..649d20e --- /dev/null +++ b/app/app/api/auth/verify/route.ts @@ -0,0 +1,191 @@ +/** + * SEP-10 Verification Endpoint + * + * POST /api/auth/verify + * + * Verifies a signed Stellar transaction (XDR) and issues a JWT if valid. + * This implements the second step of SEP-10 Web Authentication. + * + * Request body: + * { + * "transaction": "", + * "publicKey": "" + * } + * + * Response: + * { + * "token": "", + * "user": { ...user_data... } + * } + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md + */ + +import { NextRequest, NextResponse } from "next/server"; +import { verifyChallenge } from "@/lib/sep10"; +import { createToken } from "@/lib/jwt"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +// Validation schema for the request body +const verifyRequestSchema = z.object({ + transaction: z.string().min(1, "Transaction XDR is required"), + publicKey: z.string().length(56, "Invalid Stellar public key"), +}); + +/** + * POST handler for verifying SEP-10 signed challenges + */ +export async function POST(request: NextRequest): Promise { + try { + // Parse and validate request body + const body = await request.json(); + const validationResult = verifyRequestSchema.safeParse(body); + + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid request", + message: "Request body validation failed", + details: validationResult.error.errors, + }, + { status: 400 } + ); + } + + const { transaction: signedXDR, publicKey: clientPublicKey } = validationResult.data; + + // Step 1: Check if this transaction has already been used (replay attack prevention) + const transactionHash = await getTransactionHash(signedXDR); + const existingChallenge = await prisma.usedChallenge.findUnique({ + where: { transactionHash }, + }); + + if (existingChallenge) { + return NextResponse.json( + { + error: "Replay attack detected", + message: "This signature has already been used. Please request a new challenge.", + }, + { status: 403 } + ); + } + + // Step 2: Verify the signed challenge transaction + const verificationResult = verifyChallenge(signedXDR, clientPublicKey); + + if (!verificationResult.valid) { + return NextResponse.json( + { + error: "Verification failed", + message: verificationResult.error || "Invalid signature", + }, + { status: 401 } + ); + } + + // Step 3: Mark this transaction as used to prevent replay attacks + await prisma.usedChallenge.create({ + data: { + transactionHash, + publicKey: clientPublicKey, + usedAt: new Date(), + }, + }); + + // Step 4: Find or create the user + let user = await prisma.user.findUnique({ + where: { walletAddress: clientPublicKey }, + }); + + // If user doesn't exist, create a new user with default values + if (!user) { + user = await prisma.user.create({ + data: { + walletAddress: clientPublicKey, + name: `User_${clientPublicKey.slice(0, 8)}`, + username: `user_${clientPublicKey.slice(0, 8)}`, + avatarUrl: `https://api.dicebear.com/7.x/identicon/svg?seed=${clientPublicKey}`, + xp: 0, + walletBalance: 0, + }, + }); + } + + // Step 5: Generate JWT token + const token = await createToken({ + userId: user.id, + walletAddress: user.walletAddress, + username: user.name || user.username || "Anonymous", + }); + + // Step 6: Return the token and user data + return NextResponse.json( + { + token, + user: { + id: user.id, + walletAddress: user.walletAddress, + username: user.username, + name: user.name, + email: user.email, + avatarUrl: user.avatarUrl, + bio: user.bio, + xp: user.xp, + walletBalance: user.walletBalance, + createdAt: user.createdAt, + }, + }, + { + status: 200, + headers: { + // Prevent caching of authentication responses + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + } + ); + } catch (error) { + console.error("Error verifying challenge:", error); + + return NextResponse.json( + { + error: "Internal server error", + message: error instanceof Error ? error.message : "Failed to verify challenge", + }, + { status: 500 } + ); + } +} + +/** + * OPTIONS handler for CORS preflight requests + */ +export async function OPTIONS(): Promise { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }, + }); +} + +/** + * Helper function to extract transaction hash from XDR + * This is a simplified version - in production, use proper XDR parsing + */ +async function getTransactionHash(signedXDR: string): Promise { + // Import dynamically to avoid issues if stellar-sdk isn't available at build time + const { TransactionBuilder, Networks } = await import("@stellar/stellar-sdk"); + + const transaction = TransactionBuilder.fromXDR( + signedXDR, + Networks.PUBLIC + ); + + return transaction.hash().toString("hex"); +} diff --git a/app/app/api/cron/indexer/route.ts b/app/app/api/cron/indexer/route.ts new file mode 100644 index 0000000..ab0a689 --- /dev/null +++ b/app/app/api/cron/indexer/route.ts @@ -0,0 +1,71 @@ +/** + * Vercel Cron Job for Indexer + * + * This route is triggered by Vercel's cron scheduler + * Configuration in vercel.json: + * { + * "crons": [ + * { + * "path": "/api/cron/indexer", + * "schedule": "*/5 * * * *" + * } + * ] + * } + * + * Runs every 5 minutes to sync blockchain events + */ + +import { NextRequest, NextResponse } from "next/server"; +import { runIndexerOnce } from "@/lib/indexer"; + +/** + * GET handler - Triggered by Vercel Cron + */ +export async function GET(request: NextRequest): Promise { + try { + // Verify cron secret to prevent unauthorized access + const authHeader = request.headers.get("authorization"); + const cronSecret = process.env.CRON_SECRET; + + if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + console.log("[Cron] Running indexer at", new Date().toISOString()); + + const startTime = Date.now(); + await runIndexerOnce(); + const duration = Date.now() - startTime; + + console.log(`[Cron] Indexer completed in ${duration}ms`); + + return NextResponse.json({ + success: true, + message: "Indexer completed", + duration: `${duration}ms`, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error("[Cron] Indexer failed:", error); + + return NextResponse.json( + { + success: false, + error: "Indexer failed", + message: error instanceof Error ? error.message : "Unknown error", + timestamp: new Date().toISOString(), + }, + { status: 500 } + ); + } +} + +/** + * POST handler - Alternative trigger method + */ +export async function POST(request: NextRequest): Promise { + return GET(request); +} diff --git a/app/app/api/dev-users/route.ts b/app/app/api/dev-users/route.ts new file mode 100644 index 0000000..567adef --- /dev/null +++ b/app/app/api/dev-users/route.ts @@ -0,0 +1,32 @@ +import { apiSuccess } from '@/lib/api-response'; +import { prisma } from '@/lib/prisma'; + +export async function GET () { + if (process.env.NODE_ENV !== 'development') { + return apiSuccess([], 'OK', 200); + } + + const users = await prisma.user.findMany({ + include: { + badges: true, + rank: true, + _count: { + select: { + posts: true, + entries: true, + comments: true, + interactions: true, + badges: true, + analyticsEvents: true, + followings: true, + followers: true, + helpContributions: true, + accounts: true, + sessions: true, + }, + }, + }, + }); + + return apiSuccess(users, 'OK', 200); +} diff --git a/app/app/api/entries/[id]/route.ts b/app/app/api/entries/[id]/route.ts new file mode 100644 index 0000000..e11645e --- /dev/null +++ b/app/app/api/entries/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; + +/** + * DELETE /api/entries/[id] + * Delete an entry (only the owner can delete their own entry) + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getCurrentUser(request); + if (!user) return apiError('Unauthorized', 401); + + const { id: entryId } = await params; + + // Find the entry + const entry = await prisma.entry.findUnique({ + where: { id: entryId }, + include: { + post: { + select: { status: true }, + }, + }, + }); + + if (!entry) { + return apiError('Entry not found', 404); + } + + // Check ownership + if (entry.userId !== user.id) { + return apiError('You can only delete your own entries', 403); + } + + // Check if post is still open + if (entry.post.status !== 'open') { + return apiError('Cannot delete entry from a closed or completed post', 400); + } + + // Delete the entry + await prisma.entry.delete({ + where: { id: entryId }, + }); + + return apiSuccess({ id: entryId }, 'Entry deleted successfully', 200); + } catch (error) { + console.error('Error deleting entry:', error); + return apiError('Failed to delete entry', 500); + } +} diff --git a/app/app/api/health/route.ts b/app/app/api/health/route.ts new file mode 100644 index 0000000..f3957a2 --- /dev/null +++ b/app/app/api/health/route.ts @@ -0,0 +1,10 @@ +import { apiSuccess } from '@/lib/api-response'; +import { testConnection } from '@/lib/prisma'; + +export async function GET() { + const isConnected = await testConnection(); + return apiSuccess({ + status: 'ok', + database: isConnected ? 'connected' : 'disconnected' + }); +} diff --git a/app/app/api/indexer/route.ts b/app/app/api/indexer/route.ts new file mode 100644 index 0000000..791f9fb --- /dev/null +++ b/app/app/api/indexer/route.ts @@ -0,0 +1,79 @@ +/** + * Indexer API Routes + * + * POST /api/indexer/run - Manually trigger indexer run + * GET /api/indexer/stats - Get indexer statistics + * POST /api/indexer/reset - Reset indexer state (admin only) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { runIndexerOnce, getIndexerStats, resetIndexerState } from "@/lib/indexer"; +import { auth } from "@/lib/auth"; + +/** + * POST handler - Trigger indexer run + */ +export async function POST(request: NextRequest): Promise { + try { + // Check authentication for reset action + const session = await auth(); + const body = await request.json().catch(() => ({})); + + if (body.action === "reset") { + // Only admins can reset + if (!session?.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + await resetIndexerState(body.startLedger); + return NextResponse.json({ + success: true, + message: "Indexer state reset", + startLedger: body.startLedger, + }); + } + + // Run indexer once + await runIndexerOnce(); + + return NextResponse.json({ + success: true, + message: "Indexer run completed", + }); + } catch (error) { + console.error("Indexer API error:", error); + return NextResponse.json( + { + error: "Indexer failed", + message: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 } + ); + } +} + +/** + * GET handler - Get indexer statistics + */ +export async function GET(): Promise { + try { + const stats = await getIndexerStats(); + + return NextResponse.json({ + success: true, + data: stats, + }); + } catch (error) { + console.error("Indexer stats error:", error); + return NextResponse.json( + { + error: "Failed to get stats", + message: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 } + ); + } +} diff --git a/app/app/api/leaderboard/route.ts b/app/app/api/leaderboard/route.ts new file mode 100644 index 0000000..9e11c74 --- /dev/null +++ b/app/app/api/leaderboard/route.ts @@ -0,0 +1,87 @@ +import { apiError, apiSuccess } from '@/lib/api-response'; + +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET (request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const period = searchParams.get('period') || 'all-time'; + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '50'); + + // Validate pagination parameters + if (page < 1 || limit < 1 || limit > 100) { + return apiError('Invalid pagination parameters', 400); + } + + let dateFilter: Date | undefined; + if (period === 'weekly') { + dateFilter = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + } else if (period === 'monthly') { + dateFilter = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + } + + const users = await prisma.user.findMany({ + select: { + id: true, + name: true, + avatarUrl: true, + xp: true, + rank: true, + badges: { + include: { badge: true }, + }, + _count: { + select: { + posts: dateFilter + ? { where: { createdAt: { gte: dateFilter } } } + : true, + entries: dateFilter + ? { where: { createdAt: { gte: dateFilter } } } + : true, + }, + }, + }, + orderBy: { + xp: 'desc', + }, + take: limit, + skip: (page - 1) * limit, + }); + + const tierOrder = { bronze: 1, silver: 2, gold: 3, platinum: 4 }; + + const leaderboard = users.map((user) => { + const badges = user.badges + .map((ub) => ub.badge) + .sort((a, b) => + (tierOrder[b.tier as keyof typeof tierOrder] || 0) - + (tierOrder[a.tier as keyof typeof tierOrder] || 0) + ); + + return { + id: user.id, + name: user.name, + avatar_url: user.avatarUrl, + xp: user.xp, + rank: user.rank, + post_count: user._count.posts, + entry_count: user._count.entries, + total_contributions: user._count.posts + user._count.entries, + badges, + }; + }); + + return apiSuccess({ + leaderboard, + page, + limit, + period, + total: leaderboard.length, + }); + } catch (error) { + console.error('Leaderboard API error:', error); + return apiError('Failed to fetch leaderboard', 500); + } +} diff --git a/app/app/api/leaderboard/test.ts b/app/app/api/leaderboard/test.ts new file mode 100644 index 0000000..7f826c7 --- /dev/null +++ b/app/app/api/leaderboard/test.ts @@ -0,0 +1,42 @@ +/** + * Test file for the leaderboard API endpoint + * Run this with: npx tsx app/api/leaderboard/test.ts + */ + +import { GET } from './route'; + +async function testLeaderboardAPI() { + console.log('Testing Leaderboard API...\n'); + + // Test 1: Basic request + console.log('Test 1: Basic request'); + const request1 = new Request('http://localhost:3000/api/leaderboard'); + const response1 = await GET(request1 as any); + const data1 = await response1.json(); + console.log('Response:', JSON.stringify(data1, null, 2)); + console.log('Status:', response1.status); + console.log('✅ Basic request test completed\n'); + + // Test 2: With period filter + console.log('Test 2: With period filter (weekly)'); + const request2 = new Request('http://localhost:3000/api/leaderboard?period=weekly'); + const response2 = await GET(request2 as any); + const data2 = await response2.json(); + console.log('Response:', JSON.stringify(data2, null, 2)); + console.log('✅ Period filter test completed\n'); + + // Test 3: With pagination + console.log('Test 3: With pagination'); + const request3 = new Request('http://localhost:3000/api/leaderboard?page=1&limit=2'); + const response3 = await GET(request3 as any); + const data3 = await response3.json(); + console.log('Response:', JSON.stringify(data3, null, 2)); + console.log('✅ Pagination test completed\n'); + + console.log('All tests completed!'); +} + +// Run tests if this file is executed directly +if (require.main === module) { + testLeaderboardAPI().catch(console.error); +} \ No newline at end of file diff --git a/app/app/api/mock-users/route.ts b/app/app/api/mock-users/route.ts new file mode 100644 index 0000000..6d1fc9e --- /dev/null +++ b/app/app/api/mock-users/route.ts @@ -0,0 +1,35 @@ +import { apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/prisma"; + +const GET = async () => { + if (process.env.NODE_ENV === 'development') { + const users = await prisma.user.findMany({ + include: { + badges: true, + rank: true, + _count: { + select: { + posts: true, + entries: true, + comments: true, + interactions: true, + badges: true, + analyticsEvents: true, + followings: true, + followers: true, + helpContributions: true, + accounts: true, + sessions: true + } + } + }, + }) + return apiSuccess(users, "OK", 200); + } else { + return apiSuccess([], "OK", 200); + } +} + +export { + GET +} \ No newline at end of file diff --git a/app/app/api/notifications/[id]/route.ts b/app/app/api/notifications/[id]/route.ts new file mode 100644 index 0000000..b1667ac --- /dev/null +++ b/app/app/api/notifications/[id]/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; +import prisma from '@/lib/prisma'; + +// PATCH /api/notifications/[id]/read +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const userId = session.user.id; + const { id } = params; + + const notification = await prisma.notification.findUnique({ where: { id } }); + if (!notification || notification.userId !== userId) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + await prisma.notification.update({ where: { id }, data: { isRead: true } }); + return NextResponse.json({ success: true }); +} diff --git a/app/app/api/notifications/route.ts b/app/app/api/notifications/route.ts new file mode 100644 index 0000000..554dad7 --- /dev/null +++ b/app/app/api/notifications/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; +import prisma from '@/lib/prisma'; + +// GET /api/notifications?isRead=false&page=1&pageSize=20 +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const userId = session.user.id; + const { searchParams } = new URL(req.url); + const isRead = searchParams.get('isRead'); + const page = parseInt(searchParams.get('page') || '1', 10); + const pageSize = parseInt(searchParams.get('pageSize') || '20', 10); + + const where: any = { userId }; + if (isRead !== null) where.isRead = isRead === 'true'; + + const [notifications, total] = await Promise.all([ + prisma.notification.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.notification.count({ where }), + ]); + + return NextResponse.json({ notifications, total, page, pageSize }); +} diff --git a/app/app/api/posts/[id]/burn/route.ts b/app/app/api/posts/[id]/burn/route.ts new file mode 100644 index 0000000..f2c7969 --- /dev/null +++ b/app/app/api/posts/[id]/burn/route.ts @@ -0,0 +1,76 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getCurrentUser(request); + if (!user) return apiError('Unauthorized', 401); + + const { id: postId } = await params; + + // Insert burn (ignore if already exists) + await prisma.interaction.upsert({ + where: { + userId_postId_type: { + userId: user.id, + postId, + type: 'burn', + }, + }, + update: {}, + create: { + userId: user.id, + postId, + type: 'burn', + }, + }); + + // Get updated count + const count = await prisma.interaction.count({ + where: { + postId, + type: 'burn', + }, + }); + + return apiSuccess({ burned: true, count }); + } catch (error) { + return apiError('Failed to burn post', 500); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getCurrentUser(request); + if (!user) return apiError('Unauthorized', 401); + + const { id: postId } = await params; + + await prisma.interaction.deleteMany({ + where: { + userId: user.id, + postId, + type: 'burn', + }, + }); + + const count = await prisma.interaction.count({ + where: { + postId, + type: 'burn', + }, + }); + + return apiSuccess({ burned: false, count }); + } catch (error) { + return apiError('Failed to unburn post', 500); + } +} diff --git a/app/app/api/posts/[id]/entries/route.ts b/app/app/api/posts/[id]/entries/route.ts new file mode 100644 index 0000000..027e930 --- /dev/null +++ b/app/app/api/posts/[id]/entries/route.ts @@ -0,0 +1,208 @@ +import { XP_REWARDS, awardXp } from '@/lib/xp'; +import { apiError, apiSuccess } from '@/lib/api-response'; + +import { NextRequest } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { readJsonBody } from '@/lib/parse-json-body'; + +/** + * POST /api/posts/[id]/entries + * Submit an entry to a giveaway post + */ +export async function POST ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getCurrentUser(request); + if (!user) return apiError('Unauthorized', 401); + + const { id: postId } = await params; + const raw = await readJsonBody>(request); + if (!raw.ok) return raw.response; + const body = raw.data; + const { content, proofUrl } = body as { content?: unknown; proofUrl?: unknown }; + + // Validate content + if (!content || typeof content !== 'string') { + return apiError('Content is required', 400); + } + + if (content.length < 10 || content.length > 5000) { + return apiError('Content must be between 10 and 5000 characters', 400); + } + + // Check if post exists and is open + const post = await prisma.post.findUnique({ + where: { id: postId }, + select: { id: true, status: true, userId: true, type: true }, + }); + + if (!post) { + return apiError('Post not found', 404); + } + + if (post.type !== 'giveaway') { + return apiError('Entries can only be submitted to giveaway posts', 400); + } + + if (post.status !== 'open') { + return apiError('Post is not accepting entries', 400); + } + + // Prevent creators from entering their own posts + if (post.userId === user.id) { + return apiError('You cannot enter your own giveaway', 403); + } + + // Check for existing entry (unique constraint will catch this too, but we provide better error message) + const existingEntry = await prisma.entry.findUnique({ + where: { + postId_userId: { + postId, + userId: user.id, + }, + }, + }); + + if (existingEntry) { + return apiError('You have already entered this giveaway', 400); + } + + + // Create entry + const entry = await prisma.$transaction(async (tx) => { + const createdEntry = await tx.entry.create({ + data: { + postId, + userId: user.id, + content, + proofUrl: proofUrl || null, + }, + include: { + user: { + select: { + id: true, + name: true, + walletAddress: true, + avatarUrl: true, + }, + }, + }, + }); + + await awardXp( + user.id, + XP_REWARDS.enterGiveaway, + 'giveaway_entered', + { + metadata: { + postId, + entryId: createdEntry.id, + }, + }, + tx, + ); + + // Notify post owner, but ignore if notifications support is unavailable + if (post.userId && tx.notification?.create) { + try { + await tx.notification.create({ + data: { + userId: post.userId, + type: 'giveaway_entry', + message: `${user.name} entered your giveaway`, + link: `/post/${postId}`, + }, + }); + } catch (err: any) { + if (err?.code === 'P2021' && String(err?.meta?.modelName).toLowerCase() === 'notification') { + // Table does not exist, skip notification + // Optionally log: console.warn('Notification table missing, skipping notification.'); + } else { + throw err; + } + } + } + + return createdEntry; + }); + + return apiSuccess(entry, 'Entry created successfully', 201); + } catch (error) { + console.error('Error creating entry:', error); + return apiError('Failed to create entry', 500); + } +} + +/** + * GET /api/posts/[id]/entries + * Get all entries for a post with pagination + */ +export async function GET ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id: postId } = await params; + const { searchParams } = new URL(request.url); + + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '10'); + const skip = (page - 1) * limit; + + // Validate pagination params + if (page < 1 || limit < 1 || limit > 100) { + return apiError('Invalid pagination parameters', 400); + } + + // Check if post exists + const post = await prisma.post.findUnique({ + where: { id: postId }, + select: { id: true }, + }); + + if (!post) { + return apiError('Post not found', 404); + } + + // Get entries with pagination + const [entries, total] = await Promise.all([ + prisma.entry.findMany({ + where: { postId }, + include: { + user: { + select: { + id: true, + name: true, + walletAddress: true, + avatarUrl: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + prisma.entry.count({ where: { postId } }), + ]); + + return apiSuccess( + { + entries, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }, + undefined, + 200, + ); + } catch (error) { + console.error('Error fetching entries:', error); + return apiError('Failed to fetch entries', 500); + } +} diff --git a/app/app/api/posts/[id]/like/route.ts b/app/app/api/posts/[id]/like/route.ts new file mode 100644 index 0000000..7f6415f --- /dev/null +++ b/app/app/api/posts/[id]/like/route.ts @@ -0,0 +1,111 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; +import { awardXp, XP_REWARDS } from '@/lib/xp'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getCurrentUser(request); + if (!user) return apiError('Unauthorized', 401); + + const { id: postId } = await params; + + const { count } = await prisma.$transaction(async (tx) => { + const post = await tx.post.findUnique({ + where: { id: postId }, + select: { + id: true, + userId: true, + }, + }); + + if (!post) { + throw new Error('POST_NOT_FOUND'); + } + + await tx.interaction.upsert({ + where: { + userId_postId_type: { + userId: user.id, + postId, + type: 'like', + }, + }, + update: {}, + create: { + userId: user.id, + postId, + type: 'like', + }, + }); + + const likeCount = await tx.interaction.count({ + where: { + postId, + type: 'like', + }, + }); + + if (likeCount === 10) { + await awardXp( + post.userId, + XP_REWARDS.receiveTenLikes, + 'post_received_10_likes', + { + dedupeKey: `post_10_likes:${postId}`, + metadata: { + postId, + likeCount, + }, + }, + tx, + ); + } + + return { count: likeCount }; + }); + + return apiSuccess({ liked: true, count }); + } catch (error) { + if (error instanceof Error && error.message === 'POST_NOT_FOUND') { + return apiError('Post not found', 404); + } + + return apiError('Failed to like post', 500); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getCurrentUser(request); + if (!user) return apiError('Unauthorized', 401); + + const { id: postId } = await params; + + await prisma.interaction.deleteMany({ + where: { + userId: user.id, + postId, + type: 'like', + }, + }); + + const count = await prisma.interaction.count({ + where: { + postId, + type: 'like', + }, + }); + + return apiSuccess({ liked: false, count }); + } catch (error) { + return apiError('Failed to unlike post', 500); + } +} diff --git a/app/app/api/posts/[id]/route.ts b/app/app/api/posts/[id]/route.ts new file mode 100644 index 0000000..f5de40d --- /dev/null +++ b/app/app/api/posts/[id]/route.ts @@ -0,0 +1,164 @@ +import { apiError, apiSuccess } from "@/lib/api-response"; + +import { NextRequest } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { readJsonBody } from "@/lib/parse-json-body"; + +const GET = async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + try { + const { id } = await params; + const post = await prisma.post.findUnique({ + where: { id }, + include: { + user: { + select: { + id: true, + name: true, + avatarUrl: true, + }, + }, + _count: { + select: { + entries: true, + interactions: true, + }, + }, + entries: { + include: { + user: { + select: { + id: true, + name: true, + avatarUrl: true, + username: true, + } + } + } + }, + winners: { + include: { + user: { + select: { + id: true, + name: true, + avatarUrl: true, + username: true, + } + } + } + }, + }, + }); + + if (!post) { + return apiError('Post not found', 404); + } + + return apiSuccess(post); + } catch (error) { + return apiError('Failed to fetch post', 500); + } +} + +const PATCH = async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + try { + const user = await getCurrentUser(request); + if (!user) return apiError('Unauthorized', 401); + + const { id } = await params; + const raw = await readJsonBody>(request); + if (!raw.ok) return raw.response; + const body = raw.data; + + const post = await prisma.post.findUnique({ + where: { id }, + include: { + _count: { + select: { entries: true }, + }, + }, + }); + + if (!post) { + return apiError('Post not found', 404); + } + + if (post.userId !== user.id) { + return apiError('Forbidden', 403); + } + + const isOnlyStatusUpdate = body.status !== undefined && Object.keys(body).every(k => k === 'status'); + + if (post._count.entries > 0 && !isOnlyStatusUpdate) { + return apiError('Cannot edit post details with entries', 400); + } + + const updateData: any = {}; + if (body.title !== undefined) updateData.title = body.title; + if (body.description !== undefined) updateData.description = body.description; + if (body.status !== undefined) updateData.status = body.status; + + const updatedPost = await prisma.post.update({ + where: { id }, + data: updateData, + }); + + return apiSuccess(updatedPost); + } catch (error) { + return apiError('Failed to update post', 500); + } +} + +const DELETE = async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + try { + const user = await getCurrentUser(request); + if (!user) return apiError('Unauthorized', 401); + + const { id } = await params; + + const post = await prisma.post.findUnique({ + where: { id }, + include: { + _count: { + select: { entries: true }, + }, + }, + }); + + if (!post) { + return apiError('Post not found', 404); + } + + if (post.userId !== user.id) { + return apiError('Forbidden', 403); + } + + if (post._count.entries > 0) { + return apiError('Cannot delete post with entries', 400); + } + + await prisma.post.delete({ + where: { id }, + }); + + return apiSuccess({ deleted: true }); + } catch (error) { + return apiError('Failed to delete post', 500); + } +} + +export { + GET, + PATCH, + DELETE +} \ No newline at end of file diff --git a/app/app/api/posts/[id]/select-winners/route.ts b/app/app/api/posts/[id]/select-winners/route.ts new file mode 100644 index 0000000..7e6b4d2 --- /dev/null +++ b/app/app/api/posts/[id]/select-winners/route.ts @@ -0,0 +1,109 @@ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { NextRequest } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { readJsonBody } from "@/lib/parse-json-body"; + +export const POST = async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + try { + const user = await getCurrentUser(request); + if (!user) return apiError('Unauthorized', 401); + + const { id } = await params; + const raw = await readJsonBody>(request); + if (!raw.ok) return raw.response; + const body = raw.data; + + const post = await prisma.post.findUnique({ + where: { id }, + include: { + entries: true, + winners: true, + }, + }); + + if (!post) { + return apiError('Post not found', 404); + } + + if (post.userId !== user.id) { + return apiError('Forbidden', 403); + } + + if (!['open', 'active', 'in_progress'].includes(post.status)) { + return apiError(`Cannot select winners for post with status ${post.status}`, 400); + } + + if (post.winners.length > 0 && post.status === 'completed') { + return apiError('Winners already selected', 400); + } + + const method = body.method; // 'random' or 'manual' + const maxWinners = post.maxWinners || 1; + + let selectedEntries: any[] = []; + + if (method === 'random') { + if (post.entries.length === 0) { + return apiError('No entries to select from', 400); + } + const entriesToPickFrom = [...post.entries]; + // shuffle + for (let i = entriesToPickFrom.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [entriesToPickFrom[i], entriesToPickFrom[j]] = [entriesToPickFrom[j], entriesToPickFrom[i]]; + } + + selectedEntries = entriesToPickFrom.slice(0, maxWinners); + } else if (method === 'manual') { + const winnerIds = body.winnerIds as string[]; // this is array of entry ids or user ids? The prompt says `winnerIds?: string[]`. Usually this is entry IDs if selecting by entries, but wait... let's assume it's `entryId` because `post.entries` has `.id`. If they send userIds we can match by `userId`. Let's support `entryId`. Wait, "winnerIds" implies user ids or entry ids. Let's filter post.entries where `entryId` OR `userId` matches just in case. + if (!winnerIds || !Array.isArray(winnerIds) || winnerIds.length === 0) { + return apiError('Manual selection requires winnerIds', 400); + } + + selectedEntries = post.entries.filter(e => winnerIds.includes(e.id) || winnerIds.includes(e.userId)); + if (selectedEntries.length === 0) { + return apiError('No valid winners found from provided IDs', 400); + } + } else { + return apiError('Invalid selection method. Must be random or manual', 400); + } + + await prisma.$transaction(async (tx) => { + const entryIds = selectedEntries.map(e => e.id); + await tx.entry.updateMany({ + where: { id: { in: entryIds } }, + data: { isWinner: true } + }); + + const postWinnerData = selectedEntries.map(e => ({ + postId: post.id, + userId: e.userId, + assignedBy: user.id + })); + await tx.postWinner.createMany({ + data: postWinnerData, + skipDuplicates: true + }); + + await tx.post.update({ + where: { id: post.id }, + data: { status: 'completed' } + }); + }); + + const selectedUsers = selectedEntries.map(e => e.userId); + + return apiSuccess({ + message: 'Winners selected successfully', + winners: selectedUsers + }); + + } catch (error) { + console.error("Select winners error:", error); + return apiError('Failed to select winners', 500); + } +} diff --git a/app/app/api/posts/[id]/stats/route.ts b/app/app/api/posts/[id]/stats/route.ts new file mode 100644 index 0000000..65183ce --- /dev/null +++ b/app/app/api/posts/[id]/stats/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id: postId } = await params; + + const [likes, burns, entries] = await Promise.all([ + prisma.interaction.count({ + where: { postId, type: 'like' }, + }), + prisma.interaction.count({ + where: { postId, type: 'burn' }, + }), + prisma.entry.count({ + where: { postId }, + }), + ]); + + return apiSuccess({ likes, burns, entries }); + } catch (error) { + return apiError('Failed to fetch stats', 500); + } +} diff --git a/app/app/api/posts/route.ts b/app/app/api/posts/route.ts new file mode 100644 index 0000000..b0d163d --- /dev/null +++ b/app/app/api/posts/route.ts @@ -0,0 +1,245 @@ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { awardXp, XP_REWARDS } from "@/lib/xp"; + +import { Prisma } from "@prisma/client"; +import { NextRequest } from "next/server"; +import { randomUUID } from "crypto"; +import { getCurrentUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { readJsonBody } from "@/lib/parse-json-body"; +import { POST_SLUG_MAX_LENGTH, sanitizePostSlug } from "@/lib/post-slug"; + +const SLUG_SUFFIX_LENGTH = 6; + +function buildSlugWithSuffix(baseSlug: string, suffix: string) { + const maxBaseLength = POST_SLUG_MAX_LENGTH - SLUG_SUFFIX_LENGTH - 1; + const trimmedBase = baseSlug.slice(0, Math.max(1, maxBaseLength)); + + return `${trimmedBase}-${suffix}`; +} + +async function generateUniquePostSlug( + postDelegate: Pick, + title: string, + requestedSlug?: string, +) { + const baseSlug = sanitizePostSlug(requestedSlug, title); + + const existingBaseSlug = await postDelegate.findUnique({ + where: { slug: baseSlug }, + select: { id: true }, + }); + + if (!existingBaseSlug) { + return baseSlug; + } + + for (let attempt = 0; attempt < 5; attempt += 1) { + const candidate = buildSlugWithSuffix( + baseSlug, + randomUUID().replace(/-/g, "").slice(0, SLUG_SUFFIX_LENGTH), + ); + + const existingCandidate = await postDelegate.findUnique({ + where: { slug: candidate }, + select: { id: true }, + }); + + if (!existingCandidate) { + return candidate; + } + } + + return buildSlugWithSuffix( + baseSlug, + Date.now() + .toString(36) + .slice(-SLUG_SUFFIX_LENGTH) + .padStart(SLUG_SUFFIX_LENGTH, "0"), + ); +} + +function isSlugConstraintError(error: unknown) { + if (!(typeof error === "object" && error !== null && "code" in error)) { + return false; + } + + const code = (error as { code?: string }).code; + if (code !== "P2002") { + return false; + } + + const metaTarget = + "meta" in error + ? (error as { meta?: { target?: string[] | string } }).meta?.target + : undefined; + + if (!metaTarget) { + return true; + } + + if (Array.isArray(metaTarget)) { + return metaTarget.includes("slug"); + } + + return metaTarget === "slug"; +} + +const POST = async (request: NextRequest) => { + try { + const user = await getCurrentUser(request); + if (!user) return apiError("Unauthorized", 401); + + const parsed = await readJsonBody>(request); + if (!parsed.ok) return parsed.response; + + const body = parsed.data; + + const { title, description, type, winnerCount, endsAt, proofRequired } = + body as { + title?: string; + description?: string; + type?: string; + winnerCount?: unknown; + endsAt?: string; + proofRequired?: unknown; + }; + + if (!title || title.length < 10 || title.length > 200) { + return apiError("Title must be 10-200 characters", 400); + } + + if (!description || description.length < 50) { + return apiError("Description must be at least 50 characters", 400); + } + + const post = await prisma.$transaction(async (tx) => { + // ✅ Correct slug generation (ONLY THIS) + const uniqueSlug = await generateUniquePostSlug( + tx.post, + title, + body.slug, + ); + + const requirements = await tx.postRequirements.create({ + data: { proofRequired: Boolean(proofRequired) }, + }); + + const createdPost = await tx.post.create({ + data: { + userId: user.id, + type, + title, + slug: uniqueSlug, + description, + maxWinners: winnerCount ? Number(winnerCount) : null, + postRequirementsId: requirements.id, + endsAt: new Date(endsAt), + }, + include: { + user: { + select: { id: true, name: true, avatarUrl: true }, + }, + }, + }); + + if (type === "giveaway") { + await awardXp( + user.id, + XP_REWARDS.createGiveawayPost, + "giveaway_post_created", + { + metadata: { + postId: createdPost.id, + postType: type, + }, + }, + tx, + ); + } else if (type === "request") { + await awardXp( + user.id, + XP_REWARDS.createHelpRequest, + "help_request_created", + { + metadata: { + postId: createdPost.id, + postType: type, + }, + }, + tx, + ); + } + + return createdPost; + }); + + return apiSuccess(post, "Post created successfully", 201); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + isSlugConstraintError(error) + ) { + return apiError("Slug already in use", 409); + } + if (isSlugConstraintError(error)) { + return apiError("Slug already in use", 409); + } + return apiError("Failed to create post", 500); + } +}; + +const GET = async (request: NextRequest) => { + try { + const { searchParams } = new URL(request.url); + + const q = searchParams.get("q"); + const category = searchParams.get("category"); + const sort = searchParams.get("sort"); + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + + const where: any = {}; + + if (q) { + where.OR = [ + { title: { contains: q, mode: "insensitive" } }, + { description: { contains: q, mode: "insensitive" } }, + ]; + } + + if (category) { + where.category = category; + } + + let orderBy: any = [{ createdAt: "desc" }]; + if (sort === "popular") { + orderBy = [{ likes: "desc" }, { entries: "desc" }]; + } + + const [posts, total] = await Promise.all([ + prisma.post.findMany({ + where, + orderBy, + skip: (page - 1) * limit, + take: limit, + include: { + user: { + select: { + id: true, + name: true, + avatarUrl: true, + }, + }, + }, + }), + prisma.post.count({ where }), + ]); + + return apiSuccess({ posts, page, limit, total }); + } catch (error) { + return apiError("Failed to fetch posts", 500); + } +}; + +export { POST, GET }; diff --git a/app/app/api/posts/test.ts b/app/app/api/posts/test.ts new file mode 100644 index 0000000..23dcf5d --- /dev/null +++ b/app/app/api/posts/test.ts @@ -0,0 +1,137 @@ +import { DELETE, PATCH } from './[id]/route'; +import { POST, GET } from './route'; + +const testPostsAPI = async () => { + console.log('========================================'); + console.log(' Testing POSTS API endpoints '); + console.log('========================================\n'); + + const BASE_URL = 'http://localhost:3000/api/posts'; + let createdPostId: string | null = null; + + console.log('[1] GET /api/posts → basic list'); + const req1 = new Request(BASE_URL); + const res1 = await GET(req1 as any); + const json1 = await res1.json(); + + console.log(' Status :', res1.status); + console.log(' Success :', json1.success); + console.log(' Posts count :', json1.data?.posts?.length ?? '—'); + console.log(' Pagination :', json1.data?.page, 'of', json1.data?.total ?? '—'); + console.log(' First title :', json1.data?.posts?.[0]?.title || '(no posts)'); + console.log('----------------------------------------\n'); + + console.log('[2] GET /api/posts → giveaway + crypto + small page'); + const req2 = new Request( + `${BASE_URL}?page=1&limit=3&type=giveaway&category=crypto` + ); + const res2 = await GET(req2 as any); + const json2 = await res2.json(); + + console.log(' Status :', res2.status); + console.log(' Posts found :', json2.data?.posts?.length ?? '—'); + console.log('----------------------------------------\n'); + + console.log('[3] POST /api/posts → create new giveaway'); + const newPostPayload = { + title: 'API Test Giveaway – Like & Win Test Tokens', + description: + 'This post was created automatically from the test script. ' + + 'Like this post and drop your favorite emoji below to participate. ' + + 'Testing automation – please ignore.', + category: 'general', + type: 'giveaway', + winnerCount: 2, + endsAt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(), + }; + + const req3 = new Request(BASE_URL, { + method: 'POST', + body: JSON.stringify(newPostPayload), + }); + + const res3 = await POST(req3 as any); + const json3 = await res3.json(); + + console.log(' Status :', res3.status); + console.log(' Success :', json3.success); + + if (json3.success && json3.data?.id) { + createdPostId = json3.data.id; + console.log(' Created ID :', createdPostId); + console.log(' Title :', json3.data.title); + } else { + console.log(' Error :', json3.error); + } + console.log('----------------------------------------\n'); + + if (createdPostId) { + console.log('[4] GET /api/posts/[id] → fetch created post'); + const req4 = new Request(`${BASE_URL}/${createdPostId}`); + const res4 = await GET(req4 as any); + const json4 = await res4.json(); + + console.log(' Status :', res4.status); + console.log(' Success :', json4.success); + console.log(' Title :', json4.data?.title); + console.log(' _count.entries :', json4.data?._count?.entries ?? '—'); + console.log('----------------------------------------\n'); + } else { + console.log('[4] SKIPPED → no post created\n'); + } + + if (createdPostId) { + console.log('[5] PATCH /api/posts/[id] → update title'); + const patchData = { + title: 'UPDATED TITLE - ' + new Date().toISOString(), + description: 'Updated via test script', + }; + const patchRequest = new Request(`${BASE_URL}/${createdPostId}`, { + method: 'PATCH', + body: JSON.stringify(patchData), + }); + const res5 = await PATCH( + patchRequest as any, + { + params: Promise.resolve({ id: createdPostId }) + } + ); + console.log('----------------------------------------\n'); + } else { + console.log('[5] SKIPPED → no post created\n'); + } + + if (createdPostId) { + console.log('[6] DELETE /api/posts/[id] → cleanup'); + const deleteRequest = new Request(`${BASE_URL}/${createdPostId}`, { + method: 'DELETE', + }); + + const res6 = await DELETE( + deleteRequest as any, + { + params: Promise.resolve({ id: createdPostId }) + } + ); + const json6 = await res6.json(); + + console.log(' Status :', res6.status); + console.log(' Success :', json6.success); + console.log(' Message :', json6.data?.deleted ? 'Deleted' : json6.error); + console.log('----------------------------------------\n'); + } else { + console.log('[6] SKIPPED → no post created\n'); + } + + console.log('\n'); + console.log('===================================='); + console.log(' Tests completed '); + console.log('===================================='); +} + +if (require.main === module) { + testPostsAPI().catch((err) => { + console.error('\nTest suite failed:', err); + process.exit(1); + }); +} \ No newline at end of file diff --git a/app/app/api/uploads/route.ts b/app/app/api/uploads/route.ts new file mode 100644 index 0000000..2b680ef --- /dev/null +++ b/app/app/api/uploads/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from 'next/server' +import { getCurrentUser } from '@/lib/auth' +import { apiSuccess, apiError } from '@/lib/api-response' +import { uploadToS3 } from '@/lib/storage' +import { validateFile } from '@/lib/file-validation' + +export const runtime = 'nodejs' + +export async function POST(request: NextRequest) { + try { + const currentUser = await getCurrentUser(request) + if (!currentUser) return apiError('Unauthorized', 401) + + let formData: FormData + try { + formData = await request.formData() + } catch { + return apiError('Invalid multipart body', 400) + } + + const file = formData.get('file') + if (!(file instanceof File)) return apiError('No file provided', 400) + + const folder = (formData.get('folder') as string | null) ?? 'uploads' + + const validationError = validateFile(file.type, file.size) + if (validationError) return apiError(validationError.message, 422) + + try { + const buffer = Buffer.from(await file.arrayBuffer()) + const { url, key } = await uploadToS3(buffer, file.name, file.type, folder) + return apiSuccess({ url, key }, 'upload successful') + } catch (uploadError) { + console.error('[upload] S3 error:', uploadError) + return apiError('Upload failed. Please try again.', 502) + } + } catch (error) { + return apiError('Upload failed', 500) + } +} \ No newline at end of file diff --git a/app/app/api/users/[id]/activity/route.ts b/app/app/api/users/[id]/activity/route.ts new file mode 100644 index 0000000..6a135a0 --- /dev/null +++ b/app/app/api/users/[id]/activity/route.ts @@ -0,0 +1,160 @@ +'use server'; + +import { apiError, apiSuccess } from '@/lib/api-response'; + +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +type ActivityItem = { + id: string; + type: 'posted' | 'entered' | 'won' | 'liked'; + timestamp: string; // ISO + subject: { + id: string | number; + title?: string | null; + slug?: string | null; + // for entries we include entryId + entryId?: string | number; + }; + // optional raw meta for consumers that need more details (e.g. thumbnail) + meta?: Record; +}; + +const MAX_LIMIT = 100; + +export async function GET ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + if (!id) { + return apiError('Missing user id', 400); + } + + const url = new URL(request.url); + const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10)); + let limit = parseInt(url.searchParams.get('limit') || '20', 10); + if (Number.isNaN(limit) || limit <= 0) limit = 20; + limit = Math.min(limit, MAX_LIMIT); + + // Fetch activity in parallel (select only required fields) + const [posted, entered, won, liked] = await Promise.all([ + prisma.post.findMany({ + where: { userId: id }, + select: { + id: true, + title: true, + slug: true, + createdAt: true, + }, + }), + prisma.entry.findMany({ + where: { userId: id }, + select: { + id: true, + createdAt: true, + postId: true, + post: { + select: { id: true, title: true, slug: true }, + }, + }, + }), + prisma.entry.findMany({ + where: { userId: id, isWinner: true }, + select: { + id: true, + createdAt: true, + postId: true, + post: { + select: { id: true, title: true, slug: true }, + }, + }, + }), + prisma.interaction.findMany({ + where: { userId: id, type: 'like' }, + select: { + id: true, + createdAt: true, + postId: true, + post: { + select: { id: true, title: true, slug: true }, + }, + }, + }), + ]); + + // Combine and normalize + const activities: ActivityItem[] = [ + ...posted.map((p) => ({ + id: `post-${p.id}`, + type: 'posted' as const, + timestamp: p.createdAt.toISOString(), + subject: { + id: p.id, + title: p.title ?? null, + slug: p.slug ?? null, + }, + meta: {}, + })), + ...entered.map((e) => ({ + id: `entry-${e.id}`, + type: 'entered' as const, + timestamp: e.createdAt.toISOString(), + subject: { + id: e.postId, + title: e.post?.title ?? null, + slug: e.post?.slug ?? null, + entryId: e.id, + }, + meta: {}, + })), + ...won.map((e) => ({ + id: `won-${e.id}`, + type: 'won' as const, + timestamp: e.createdAt.toISOString(), + subject: { + id: e.postId, + title: e.post?.title ?? null, + slug: e.post?.slug ?? null, + entryId: e.id, + }, + meta: {}, + })), + ...liked.map((i) => ({ + id: `like-${i.id}`, + type: 'liked' as const, + timestamp: i.createdAt.toISOString(), + subject: { + id: i.postId, + title: i.post?.title ?? null, + slug: i.post?.slug ?? null, + }, + meta: {}, + })), + ]; + + // Sort newest first + activities.sort((a, b) => { + const ta = new Date(a.timestamp).getTime(); + const tb = new Date(b.timestamp).getTime(); + return tb - ta; + }); + + const total = activities.length; + const totalPages = Math.max(1, Math.ceil(total / limit)); + const start = (page - 1) * limit; + const paginated = activities.slice(start, start + limit); + + return apiSuccess({ + activity: paginated, + page, + limit, + total, + totalPages, + }); + } catch (error) { + console.error('GET /api/users/[id]/activity error', error); + return apiError('Failed to fetch activity', 500); + } +} \ No newline at end of file diff --git a/app/app/api/users/[id]/entries/route.ts b/app/app/api/users/[id]/entries/route.ts new file mode 100644 index 0000000..2a793d4 --- /dev/null +++ b/app/app/api/users/[id]/entries/route.ts @@ -0,0 +1,50 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + + // Try to fetch from database first + try { + // Verify user exists + const userExists = await prisma.user.findUnique({ + where: { id }, + select: { id: true }, + }); + + if (!userExists) { + return apiError('User not found', 404); + } + + // Fetch user's entries + const userEntries = await prisma.entry.findMany({ + where: { userId: id }, + orderBy: { createdAt: 'desc' }, + include: { + post: { + select: { + id: true, + title: true, + type: true, + status: true, + createdAt: true, + }, + }, + }, + }); + + return apiSuccess(userEntries); + + } catch (dbError) { + return apiError('Failed to fetch user entries', 500); + } + + } catch (error) { + return apiError('Failed to fetch user entries', 500); + } +} \ No newline at end of file diff --git a/app/app/api/users/[id]/follow/route.ts b/app/app/api/users/[id]/follow/route.ts new file mode 100644 index 0000000..5354cb0 --- /dev/null +++ b/app/app/api/users/[id]/follow/route.ts @@ -0,0 +1,84 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const currentUser = await getCurrentUser(request); + if (!currentUser) return apiError('Unauthorized', 401); + + const { id: targetUserId } = await params; + + if (currentUser.id === targetUserId) { + return apiError('Cannot follow yourself', 400); + } + + const targetUser = await prisma.user.findUnique({ + where: { id: targetUserId } + }); + + if (!targetUser) { + return apiError('User not found', 404); + } + + // Use findFirst then create instead of upsert since the compound index has an optional field + // which may cause issues if we aren't careful, though Prisma supports it. + const existing = await prisma.follow.findUnique({ + where: { + userId_followingId: { + userId: currentUser.id, + followingId: targetUserId, + } + } + }); + + if (!existing) { + const follow = await prisma.follow.create({ + data: { + userId: currentUser.id, + followingId: targetUserId, + } + }); + return apiSuccess({ success: true, follow }, undefined, 201); + } + + return apiSuccess({ success: true, follow: existing }, undefined, 200); + } catch (error) { + console.error('Failed to follow user:', error); + return apiError('Failed to follow user', 500); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const currentUser = await getCurrentUser(request); + if (!currentUser) return apiError('Unauthorized', 401); + + const { id: targetUserId } = await params; + + try { + await prisma.follow.delete({ + where: { + userId_followingId: { + userId: currentUser.id, + followingId: targetUserId, + } + } + }); + } catch(e) { + // Ignore if not found + } + + return apiSuccess({ success: true }, undefined, 200); + } catch (error) { + console.error('Failed to unfollow user:', error); + return apiError('Failed to unfollow user', 500); + } +} diff --git a/app/app/api/users/[id]/followers/route.ts b/app/app/api/users/[id]/followers/route.ts new file mode 100644 index 0000000..6ca4d0f --- /dev/null +++ b/app/app/api/users/[id]/followers/route.ts @@ -0,0 +1,52 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id: targetUserId } = await params; + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get('limit') || '20'); + const skip = parseInt(searchParams.get('skip') || '0'); + + // Followers are users who follow the target user + // i.e., followingId = targetUserId + // we want to return the `user` relation (the follower) + const followers = await prisma.follow.findMany({ + where: { followingId: targetUserId }, + include: { + user: { + select: { + id: true, + username: true, + name: true, + avatarUrl: true, + xp: true, + walletAddress: true, + bio: true, + } + } + }, + take: limit, + skip, + orderBy: { id: 'desc' } + }); + + const total = await prisma.follow.count({ + where: { followingId: targetUserId } + }); + + return apiSuccess({ + items: followers.map(f => f.user).filter(Boolean), + total, + limit, + skip + }); + } catch (error) { + console.error('Failed to fetch followers:', error); + return apiError('Failed to fetch followers', 500); + } +} diff --git a/app/app/api/users/[id]/following/route.ts b/app/app/api/users/[id]/following/route.ts new file mode 100644 index 0000000..8e60fe2 --- /dev/null +++ b/app/app/api/users/[id]/following/route.ts @@ -0,0 +1,52 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id: targetUserId } = await params; + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get('limit') || '20'); + const skip = parseInt(searchParams.get('skip') || '0'); + + // Following are users the target user follows + // i.e., userId = targetUserId, followingId != null + // we want to return the `following` relation (the person being followed) + const following = await prisma.follow.findMany({ + where: { userId: targetUserId, followingId: { not: null } }, + include: { + following: { + select: { + id: true, + username: true, + name: true, + avatarUrl: true, + xp: true, + walletAddress: true, + bio: true, + } + } + }, + take: limit, + skip, + orderBy: { id: 'desc' } + }); + + const total = await prisma.follow.count({ + where: { userId: targetUserId, followingId: { not: null } } + }); + + return apiSuccess({ + items: following.map(f => f.following).filter(Boolean), + total, + limit, + skip + }); + } catch (error) { + console.error('Failed to fetch following:', error); + return apiError('Failed to fetch following', 500); + } +} diff --git a/app/app/api/users/[id]/posts/route.ts b/app/app/api/users/[id]/posts/route.ts new file mode 100644 index 0000000..fa3010f --- /dev/null +++ b/app/app/api/users/[id]/posts/route.ts @@ -0,0 +1,51 @@ +import { apiError, apiSuccess } from '@/lib/api-response'; + +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + + // Try to fetch from database first + try { + // Verify user exists + const userExists = await prisma.user.findUnique({ + where: { id }, + select: { id: true }, + }); + + if (!userExists) { + return apiError('User not found', 404); + } + + // Fetch user's posts + const userPosts = await prisma.post.findMany({ + where: { userId: id }, + orderBy: { createdAt: 'desc' }, + include: { + entries: { + select: { + id: true, + userId: true, + content: true, + isWinner: true, + createdAt: true, + }, + }, + }, + }); + + return apiSuccess(userPosts); + + } catch (dbError) { + return apiError('Failed to fetch user posts', 500); + } + + } catch (error) { + return apiError('Failed to fetch user posts', 500); + } +} \ No newline at end of file diff --git a/app/app/api/users/[id]/route.ts b/app/app/api/users/[id]/route.ts new file mode 100644 index 0000000..e08c383 --- /dev/null +++ b/app/app/api/users/[id]/route.ts @@ -0,0 +1,140 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; +import { readJsonBody } from '@/lib/parse-json-body'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + + try { + const currentUser = await getCurrentUser(request); + + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + walletAddress: true, + name: true, + username: true, + bio: true, + email: true, + avatarUrl: true, + xp: true, + walletBalance: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + followers: true, + followings: true, + } + } + }, + }); + + if (user) { + let isFollowing = false; + if (currentUser) { + const follow = await prisma.follow.findUnique({ + where: { + userId_followingId: { + userId: currentUser.id, + followingId: id, + } + } + }); + isFollowing = !!follow; + } + + return apiSuccess({ ...user, isFollowing }); + } + } catch (dbError) { + // Database error - fallback already handled above + } + + return apiError('User not found', 404); + } catch (error) { + return apiError('Failed to fetch user', 500); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const currentUser = await getCurrentUser(request); + if (!currentUser) return apiError('Unauthorized', 401); + + const { id } = await params; + + if (currentUser.id !== id) { + return apiError('Can only update own profile', 403); + } + + const raw = await readJsonBody>(request); + if (!raw.ok) return raw.response; + const { name, username, bio, email } = raw.data; + + try { + // --- Uniqueness checks --- + // These run as a single query each so we can return a field-specific error + // message instead of letting Prisma throw a generic unique-constraint error. + if (username !== undefined) { + const existing = await prisma.user.findFirst({ + where: { username, NOT: { id } }, + select: { id: true }, + }); + if (existing) { + return apiError('Username is already taken', 409); + } + } + + if (email !== undefined) { + const existing = await prisma.user.findFirst({ + where: { email, NOT: { id } }, + select: { id: true }, + }); + if (existing) { + return apiError('Email address is already in use', 409); + } + } + + // --- Perform the update --- + const updatedUser = await prisma.user.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(username !== undefined && { username }), + ...(bio !== undefined && { bio }), + ...(email !== undefined && { email }), + updatedAt: new Date(), + }, + select: { + id: true, + walletAddress: true, + name: true, + username: true, + bio: true, + email: true, + avatarUrl: true, + xp: true, + walletBalance: true, + createdAt: true, + updatedAt: true, + }, + }); + + return apiSuccess(updatedUser); + } catch (dbError) { + return apiError('Failed to update profile', 500); + } + } catch (error) { + return apiError('Failed to update profile', 500); + } +} \ No newline at end of file diff --git a/app/app/api/users/[id]/stats/route.ts b/app/app/api/users/[id]/stats/route.ts new file mode 100644 index 0000000..308c785 --- /dev/null +++ b/app/app/api/users/[id]/stats/route.ts @@ -0,0 +1,54 @@ +import { apiError, apiSuccess } from '@/lib/api-response'; + +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + + // Try to fetch from database first + try { + // Fetch user to verify existence + const user = await prisma.user.findUnique({ + where: { id }, + select: { xp: true }, + }); + + if (!user) { + return apiError('User not found', 404); + } + + // Calculate stats using database queries + const [totalPosts, totalEntries, wins] = await Promise.all([ + prisma.post.count({ + where: { userId: id }, + }), + prisma.entry.count({ + where: { userId: id }, + }), + prisma.entry.count({ + where: { + userId: id, + isWinner: true + }, + }), + ]); + + return apiSuccess({ + total_posts: totalPosts, + total_entries: totalEntries, + wins, + xp: user.xp || 0, + }); + } catch (dbError) { + return apiError('Failed to fetch stats', 500); + } + + } catch (error) { + return apiError('Failed to fetch stats', 500); + } +} \ No newline at end of file diff --git a/app/app/api/wallet/balance/route.ts b/app/app/api/wallet/balance/route.ts new file mode 100644 index 0000000..27e14bd --- /dev/null +++ b/app/app/api/wallet/balance/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from 'next/server'; +import { apiError, apiSuccess } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; + +export async function GET(request: NextRequest) { + try { + const currentUser = await getCurrentUser(request); + if (!currentUser) return apiError('Unauthorized', 401); + + return apiSuccess({ balance: currentUser.walletBalance }); + } catch { + return apiError('Failed to fetch wallet balance', 500); + } +} diff --git a/app/app/api/wallet/fund/route.ts b/app/app/api/wallet/fund/route.ts new file mode 100644 index 0000000..a05612a --- /dev/null +++ b/app/app/api/wallet/fund/route.ts @@ -0,0 +1,55 @@ +import { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { apiError, apiSuccess } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { readJsonBody } from '@/lib/parse-json-body'; + +const fundSchema = z.object({ + amount: z.number().positive('Amount must be greater than 0'), + method: z.enum(['card', 'bank', 'crypto']).default('card'), + note: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + try { + const currentUser = await getCurrentUser(request); + if (!currentUser) return apiError('Unauthorized', 401); + + const raw = await readJsonBody(request); + if (!raw.ok) return raw.response; + const parsed = fundSchema.safeParse(raw.data); + if (!parsed.success) { + return apiError(parsed.error.errors[0].message, 400); + } + + const { amount, method, note } = parsed.data; + + const [transaction, updatedUser] = await prisma.$transaction([ + prisma.walletTransaction.create({ + data: { + userId: currentUser.id, + type: 'fund', + amount, + currency: 'USD', + status: 'completed', + method, + note: note ?? null, + }, + }), + prisma.user.update({ + where: { id: currentUser.id }, + data: { walletBalance: { increment: amount } }, + select: { walletBalance: true }, + }), + ]); + + return apiSuccess( + { balance: updatedUser.walletBalance, transaction }, + 'Wallet funded successfully', + 201, + ); + } catch { + return apiError('Failed to fund wallet', 500); + } +} diff --git a/app/app/api/wallet/transactions/route.ts b/app/app/api/wallet/transactions/route.ts new file mode 100644 index 0000000..9c91ec9 --- /dev/null +++ b/app/app/api/wallet/transactions/route.ts @@ -0,0 +1,30 @@ +import { NextRequest } from 'next/server'; +import { apiError, apiSuccess } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export async function GET(request: NextRequest) { + try { + const currentUser = await getCurrentUser(request); + if (!currentUser) return apiError('Unauthorized', 401); + + const { searchParams } = new URL(request.url); + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10)); + const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') ?? '20', 10))); + const skip = (page - 1) * limit; + + const [transactions, total] = await Promise.all([ + prisma.walletTransaction.findMany({ + where: { userId: currentUser.id }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + prisma.walletTransaction.count({ where: { userId: currentUser.id } }), + ]); + + return apiSuccess({ transactions, total, page, limit }); + } catch { + return apiError('Failed to fetch transactions', 500); + } +} diff --git a/app/app/api/wallet/withdraw/route.ts b/app/app/api/wallet/withdraw/route.ts new file mode 100644 index 0000000..e8e5de5 --- /dev/null +++ b/app/app/api/wallet/withdraw/route.ts @@ -0,0 +1,70 @@ +import { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { apiError, apiSuccess } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { readJsonBody } from '@/lib/parse-json-body'; + +const withdrawSchema = z.object({ + amount: z.number().positive('Amount must be greater than 0'), + method: z.enum(['bank', 'crypto', 'card']).default('bank'), + note: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + try { + const currentUser = await getCurrentUser(request); + if (!currentUser) return apiError('Unauthorized', 401); + + const raw = await readJsonBody(request); + if (!raw.ok) return raw.response; + const parsed = withdrawSchema.safeParse(raw.data); + if (!parsed.success) { + return apiError(parsed.error.errors[0].message, 400); + } + + const { amount, method, note } = parsed.data; + + const result = await prisma.$transaction(async (tx) => { + const user = await tx.user.findUnique({ + where: { id: currentUser.id }, + select: { walletBalance: true }, + }); + + if (!user) throw new Error('USER_NOT_FOUND'); + if (user.walletBalance < amount) throw new Error('INSUFFICIENT_BALANCE'); + + const transaction = await tx.walletTransaction.create({ + data: { + userId: currentUser.id, + type: 'withdraw', + amount, + currency: 'USD', + status: 'pending', + method, + note: note ?? null, + }, + }); + + const updatedUser = await tx.user.update({ + where: { id: currentUser.id }, + data: { walletBalance: { decrement: amount } }, + select: { walletBalance: true }, + }); + + return { transaction, balance: updatedUser.walletBalance }; + }); + + return apiSuccess(result, 'Withdrawal initiated successfully'); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'INSUFFICIENT_BALANCE') { + return apiError('Insufficient wallet balance', 400); + } + if (error.message === 'USER_NOT_FOUND') { + return apiError('User not found', 404); + } + } + return apiError('Failed to process withdrawal', 500); + } +} diff --git a/app/favicon.ico b/app/app/favicon.ico similarity index 100% rename from app/favicon.ico rename to app/app/favicon.ico diff --git a/app/app/feed/page.tsx b/app/app/feed/page.tsx new file mode 100644 index 0000000..51bf9d2 --- /dev/null +++ b/app/app/feed/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { AuthGuard } from '@/components/auth-guard'; +import { TimelineFeed } from '@/components/timeline-feed'; + +export default function FeedPage() { + return ( + +
+ +
+
+ ); +} diff --git a/app/globals.css b/app/app/globals.css similarity index 100% rename from app/globals.css rename to app/app/globals.css diff --git a/app/layout.tsx b/app/app/layout.tsx similarity index 59% rename from app/layout.tsx rename to app/app/layout.tsx index 9dffa3d..85dbfad 100644 --- a/app/layout.tsx +++ b/app/app/layout.tsx @@ -4,6 +4,7 @@ import { AppLayout } from '@/components/app-layout'; import { AppProvider } from '@/contexts/app-context'; import type { Metadata } from 'next'; import type React from 'react'; +import { SessionProvider } from 'next-auth/react'; import { ThemeProvider } from '@/components/theme-provider'; import { Toaster } from '@/components/ui/sonner'; @@ -20,17 +21,19 @@ export default function RootLayout({ return ( - - - {children} - - - + + + + {children} + + + + ); diff --git a/app/app/leaderboard/page.tsx b/app/app/leaderboard/page.tsx new file mode 100644 index 0000000..c4e7714 --- /dev/null +++ b/app/app/leaderboard/page.tsx @@ -0,0 +1,396 @@ +'use client'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Crown, + Gift, + Heart, + Medal, + Star, + TrendingUp, + Trophy, + Users, +} from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useEffect, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type FilterPeriod = 'week' | 'month' | 'all-time'; + +/** Shape of each entry in the API response's `leaderboard` array. */ +type LeaderboardUser = { + id: string; + name: string; + avatar_url: string | null; + xp: number; + post_count: number; + entry_count: number; + total_contributions: number; + badges: { + id: string; + name: string; + tier: string; + }[]; +}; + +type LeaderboardResponse = { + leaderboard: LeaderboardUser[]; + page: number; + limit: number; + period: string; + total: number; +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Map the UI's period values to what the API expects. + * The UI uses 'week'/'month'/'all-time'; the API uses 'weekly'/'monthly'/'all-time'. + */ +function toPeriodParam(period: FilterPeriod): string { + if (period === 'week') return 'weekly'; + if (period === 'month') return 'monthly'; + return 'all-time'; +} + +function getMedalIcon(rank: number) { + if (rank === 1) return ; + if (rank === 2) return ; + if (rank === 3) return ; + return ; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function LeaderboardPage() { + const [selectedPeriod, setSelectedPeriod] = useState('all-time'); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch from the live API whenever the period filter changes. + useEffect(() => { + const fetchLeaderboard = async () => { + setIsLoading(true); + setError(null); + try { + const res = await fetch( + `/api/leaderboard?period=${toPeriodParam(selectedPeriod)}&page=1&limit=50` + ); + if (!res.ok) throw new Error(`Request failed: ${res.status}`); + const json = await res.json(); + setData(json.data); // apiSuccess wraps the payload in { data: ... } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load leaderboard'); + } finally { + setIsLoading(false); + } + }; + + fetchLeaderboard(); + }, [selectedPeriod]); + + // Derive the per-tab arrays from the single API response. + // The API ranks users by XP, but individual tabs still re-sort for their local metric. + const users = data?.leaderboard ?? []; + + /** Top Givers — users who have created the most posts (giveaways). */ + const topGivers = [...users] + .sort((a, b) => b.post_count - a.post_count) + .slice(0, 20); + + /** Top Requestors — users who have made the most entries/requests. */ + const topRequestors = [...users] + .sort((a, b) => b.entry_count - a.entry_count) + .slice(0, 20); + + /** Trending — users with the highest overall activity (posts + entries). */ + const trendingUsers = [...users] + .sort((a, b) => { + if (b.xp !== a.xp) return b.xp - a.xp; + return b.total_contributions - a.total_contributions; + }) + .slice(0, 20); + + // ── Render helpers ────────────────────────────────────────────────────────── + + const renderSkeleton = () => ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); + + const renderError = () => ( +
+

Failed to load leaderboard

+

{error}

+ +
+ ); + + const renderUserRow = ( + u: LeaderboardUser, + idx: number, + metric: { value: number; label: string; colorClass: string }, + ) => ( + +
+
+ {getMedalIcon(idx + 1)} +
+ + #{idx + 1} + + + + + {u.name + .split(' ') + .map((n) => n[0]) + .join('')} + + +
+

+ {u.name} +

+

+ {u.xp.toLocaleString()} XP +

+
+
+
{metric.value}
+
+ {metric.label} +
+
+
+
{u.badges.length}
+
Badges
+
+
+ + ); + + // ── Markup ────────────────────────────────────────────────────────────────── + + return ( +
+ {/* Header */} +
+
+
+ +

+ Leaderboards +

+
+

+ Celebrate the most active members of our community +

+
+
+ + {/* Period Filter */} +
+
+ {(['week', 'month', 'all-time'] as const).map((period) => ( + + ))} +
+
+ + {/* Leaderboard Tabs */} +
+ + + + + Top Givers + + + + Giveaways + + + + Requestors + + + + Requests + + + + Trending + + + + {/* Top Givers */} + + + + + + Top Givers + + + + {isLoading ? renderSkeleton() : error ? renderError() : ( +
+ {topGivers.map((u, idx) => + renderUserRow(u, idx, { + value: u.post_count, + label: 'Giveaways', + colorClass: 'text-orange-600', + }) + )} +
+ )} +
+
+
+ + {/* Top Giveaways — requires a dedicated posts endpoint */} + + + + + + Trending Giveaways + + + + {isLoading ? renderSkeleton() : error ? renderError() : ( +
+ +

Trending giveaways coming soon

+

+ This tab will show top posts once the posts leaderboard endpoint is available. +

+
+ )} +
+
+
+ + {/* Top Requestors */} + + + + + + Top Requestors + + + + {isLoading ? renderSkeleton() : error ? renderError() : ( +
+ {topRequestors.map((u, idx) => + renderUserRow(u, idx, { + value: u.entry_count, + label: 'Requests', + colorClass: 'text-red-600', + }) + )} +
+ )} +
+
+
+ + {/* Top Requests — requires a dedicated posts endpoint */} + + + + + + Trending Requests + + + + {isLoading ? renderSkeleton() : error ? renderError() : ( +
+ +

Trending requests coming soon

+

+ This tab will show top posts once the posts leaderboard endpoint is available. +

+
+ )} +
+
+
+ + {/* Trending */} + + + + + + Trending Members + + + + {isLoading ? renderSkeleton() : error ? renderError() : ( +
+ {trendingUsers.map((u, idx) => + renderUserRow(u, idx, { + value: u.xp, + label: 'XP', + colorClass: 'text-yellow-600', + }) + )} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/app/app/not-found.tsx b/app/app/not-found.tsx new file mode 100644 index 0000000..961922b --- /dev/null +++ b/app/app/not-found.tsx @@ -0,0 +1,32 @@ +import { Button } from '@/components/ui/button'; +import { FileQuestion } from 'lucide-react'; +import Link from 'next/link'; + +/** + * Custom 404 Not Found page for Next.js App Router. + */ +export default function NotFound() { + return ( +
+
+
+
+ +
+

404

+

Page not found

+

+ The page you are looking for might have been removed, had its name + changed, or is temporarily unavailable. +

+ +
+
+ ); +} diff --git a/app/app/notifications/page.tsx b/app/app/notifications/page.tsx new file mode 100644 index 0000000..a81b033 --- /dev/null +++ b/app/app/notifications/page.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; + +export default function NotificationsPage() { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchNotifications() { + setLoading(true); + const res = await fetch('/api/notifications?page=1&pageSize=50'); + const data = await res.json(); + setNotifications(data.notifications || []); + setLoading(false); + } + fetchNotifications(); + }, []); + + return ( +
+

Notifications

+ {loading ? ( +
Loading...
+ ) : notifications.length === 0 ? ( +
No notifications yet.
+ ) : ( +
    + {notifications.map((n) => ( +
  • +
    +
    + {n.message} + {n.link && ( + View + )} +
    + {!n.isRead && New} +
    +
    {new Date(n.createdAt).toLocaleString()}
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/page.tsx b/app/app/page.tsx similarity index 59% rename from app/page.tsx rename to app/app/page.tsx index f0bde9e..6e82aeb 100644 --- a/app/page.tsx +++ b/app/app/page.tsx @@ -1,45 +1,148 @@ -"use client" - -import { useApp } from "@/contexts/app-context" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { GuestNavbar } from "@/components/guest-navbar" -import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" -import { ArrowRight, Gift, Heart, Users, Zap, TrendingUp, Crown } from "lucide-react" -import Link from "next/link" -import { useRouter } from "next/navigation" -import { useEffect, useState } from "react" -import { mockUsers, mockPosts } from "@/lib/mock-data" +'use client'; + +import { + ArrowRight, + Crown, + Gift, + Heart, + TrendingUp, + Users, + Zap, +} from 'lucide-react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useEffect, useState } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { GuestNavbar } from '@/components/guest-navbar'; +import Link from 'next/link'; +import { useAppContext } from '@/contexts/app-context'; +import { useRouter } from 'next/navigation'; + +type LandingUser = { + id: string; + name: string; + username?: string; + avatarUrl?: string; + avatar_url?: string; + isVerified?: boolean; + is_verified?: boolean; + followersCount?: number; + followers_count?: number; + postsCount?: number; + post_count?: number; + badges?: unknown[]; + _count?: { + followers?: number; + posts?: number; + badges?: number; + }; +}; + +type LandingPost = { + id: string; + title: string; + description?: string | null; + type?: 'giveaway' | 'request'; + media?: Array<{ url?: string | null }>; + entriesCount?: number; + likesCount?: number; +}; export default function LandingPage() { - const { user } = useApp() - const router = useRouter() - const [scrollY, setScrollY] = useState(0) + const { user } = useAppContext(); + const router = useRouter(); + const [scrollY, setScrollY] = useState(0); + const [topGivers, setTopGivers] = useState([]); + const [trendingGiveaways, setTrendingGiveaways] = useState([]); + const [trendingRequests, setTrendingRequests] = useState([]); useEffect(() => { if (user) { - router.push("/feed") + router.push('/feed'); } - }, [user, router]) + }, [user, router]); useEffect(() => { - const handleScroll = () => setScrollY(window.scrollY) - window.addEventListener("scroll", handleScroll) - return () => window.removeEventListener("scroll", handleScroll) - }, []) + const handleScroll = () => setScrollY(window.scrollY); + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); - if (user) { - return null - } + useEffect(() => { + let isCancelled = false; + + const fetchLandingData = async () => { + try { + const [leaderboardRes, giveawaysRes, requestsRes] = await Promise.all([ + fetch('/api/leaderboard?limit=3'), + fetch('/api/posts?type=giveaway&limit=3'), + fetch('/api/posts?type=request&limit=3'), + ]); - // Get top givers (users with highest followers) - const topGivers = mockUsers.slice(0, 3).sort((a, b) => b.followersCount - a.followersCount) + const [leaderboardJson, giveawaysJson, requestsJson] = await Promise.all([ + leaderboardRes.json(), + giveawaysRes.json(), + requestsRes.json(), + ]); - // Get trending giveaways - const trendingGiveaways = mockPosts.filter((p) => p.type === "giveaway").slice(0, 3) + if (isCancelled) return; + + const leaderboardUsers = Array.isArray(leaderboardJson?.data?.leaderboard) + ? leaderboardJson.data.leaderboard + : []; + const giveawaysPosts = Array.isArray(giveawaysJson?.data?.posts) + ? giveawaysJson.data.posts + : []; + const requestPosts = Array.isArray(requestsJson?.data?.posts) + ? requestsJson.data.posts + : []; + + setTopGivers( + leaderboardUsers.slice(0, 3).map((giver: LandingUser) => ({ + ...giver, + avatarUrl: giver.avatarUrl ?? giver.avatar_url, + username: + giver.username || + (giver.name + ? giver.name.toLowerCase().replace(/\s+/g, '') + : `user-${giver.id.slice(0, 6)}`), + isVerified: Boolean(giver.isVerified ?? giver.is_verified), + _count: { + followers: + giver._count?.followers ?? + giver.followersCount ?? + giver.followers_count ?? + 0, + posts: + giver._count?.posts ?? giver.postsCount ?? giver.post_count ?? 0, + badges: giver._count?.badges ?? giver.badges?.length ?? 0, + }, + })), + ); + setTrendingGiveaways(giveawaysPosts.slice(0, 3)); + setTrendingRequests(requestPosts.slice(0, 3)); + } catch (error) { + console.error('Failed to load landing page data:', error); + } + }; + + fetchLandingData(); + + return () => { + isCancelled = true; + }; + }, []); + + if (user) { + return null; + } - // Get trending requests - const trendingRequests = mockPosts.filter((p) => p.type === "help-request").slice(0, 3) + const getFollowerCount = (u: any) => + u?._count?.followers ?? u?.followersCount ?? u?.followers_count ?? 0; + const getPostCount = (u: any) => + u?._count?.posts ?? u?.postsCount ?? u?.post_count ?? 0; + const getBadgeCount = (u: any) => u?._count?.badges ?? u?.badges?.length ?? 0; return (
@@ -66,19 +169,26 @@ export default function LandingPage() {

- Join a community of generous givers and builders. Create giveaways, request help, earn badges, and make a - real impact. + Join a community of generous givers and builders. Create giveaways, + request help, earn badges, and make a real impact.

- - @@ -90,8 +200,12 @@ export default function LandingPage() {
-

How Geev Works

-

Three simple ways to make a difference

+

+ How Geev Works +

+

+ Three simple ways to make a difference +

@@ -99,10 +213,12 @@ export default function LandingPage() {
-

Create Giveaways

+

+ Create Giveaways +

- Share your success by creating giveaways for your community. Set prizes, requirements, and watch people - engage. + Share your success by creating giveaways for your community. Set + prizes, requirements, and watch people engage.

@@ -110,10 +226,12 @@ export default function LandingPage() {
-

Request Help

+

+ Request Help +

- Need support for a project or personal goal? Create a help request and let the community rally behind - you. + Need support for a project or personal goal? Create a help + request and let the community rally behind you.

@@ -121,9 +239,12 @@ export default function LandingPage() {
-

Build Community

+

+ Build Community +

- Connect with like-minded people, earn badges, climb rankings, and be part of something bigger. + Connect with like-minded people, earn badges, climb rankings, + and be part of something bigger.

@@ -135,9 +256,13 @@ export default function LandingPage() {
-

Top Givers

+

+ Top Givers +

-

Meet the community members making the biggest impact

+

+ Meet the community members making the biggest impact +

{topGivers.map((giver, idx) => ( @@ -145,12 +270,15 @@ export default function LandingPage() {
- + {giver.name - .split(" ") + .split(' ') .map((n) => n[0]) - .join("")} + .join('')}
@@ -164,21 +292,35 @@ export default function LandingPage() { )}
-

@{giver.username}

+

+ @{giver.username} +

-
{giver.postsCount}
-
Posts
+
+ {getPostCount(giver)} +
+
+ Posts +
-
{(giver.followersCount / 1000).toFixed(1)}K
-
Followers
+
+ {(getFollowerCount(giver) / 1000).toFixed(1)}K +
+
+ Followers +
-
{giver.badges?.length || 0}
-
Badges
+
+ {getBadgeCount(giver)} +
+
+ Badges +
@@ -194,9 +336,13 @@ export default function LandingPage() {
-

Trending Giveaways

+

+ Trending Giveaways +

-

Popular giveaways happening right now

+

+ Popular giveaways happening right now +

{trendingGiveaways.map((post) => ( @@ -204,18 +350,28 @@ export default function LandingPage() {
-

{post.title}

-

{post.description}

+

+ {post.title} +

+

+ {post.description} +

{post.entriesCount || 0} Entries -
{post.likesCount || 0} 🔥
+
+ {post.likesCount || 0} 🔥 +
@@ -227,9 +383,13 @@ export default function LandingPage() {
-

Help Requests

+

+ Help Requests +

-

Community members asking for support

+

+ Community members asking for support +

{trendingRequests.map((post) => ( @@ -237,18 +397,28 @@ export default function LandingPage() {
-

{post.title}

-

{post.description}

+

+ {post.title} +

+

+ {post.description} +

{post.entriesCount || 0} Responses -
{post.likesCount || 0} 🔥
+
+ {post.likesCount || 0} 🔥 +
@@ -264,20 +434,32 @@ export default function LandingPage() {
-
2.5K+
-
Active Users
+
+ 2.5K+ +
+
+ Active Users +
-
$50K+
+
+ $50K+ +
Given Away
-
1.2K+
+
+ 1.2K+ +
Giveaways
-
800+
-
People Helped
+
+ 800+ +
+
+ People Helped +
@@ -286,12 +468,17 @@ export default function LandingPage() { {/* CTA Section */}
-

Ready to Make a Difference?

+

+ Ready to Make a Difference? +

Join thousands of givers and builders. Start your journey today.

- @@ -305,30 +492,50 @@ export default function LandingPage() {
- Geev - Geev + Geev + Geev {/* Geev */}

- Building stronger communities through social giving and web3 technology. + Building stronger communities through social giving and web3 + technology.

-

Platform

+

+ Platform +

  • - + Browse Giveaways
  • - + Find Help Requests
  • - + Get Started
  • @@ -336,20 +543,31 @@ export default function LandingPage() {
-

Community

+

+ Community +

-

Legal

+

+ Legal +

  • - + Privacy Policy
  • - + Terms of Service
  • - + Contact
  • @@ -380,7 +609,9 @@ export default function LandingPage() {
    -

    © 2026 Geev. All rights reserved.

    +

    + © 2026 Geev. All rights reserved. +

    Built with ❤️ for the community, by the community

    @@ -389,5 +620,5 @@ export default function LandingPage() {
    - ) + ); } diff --git a/app/app/post/[postId]/page.tsx b/app/app/post/[postId]/page.tsx new file mode 100644 index 0000000..5488dda --- /dev/null +++ b/app/app/post/[postId]/page.tsx @@ -0,0 +1,745 @@ +"use client"; + +import { + ArrowLeft, + Calendar, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + Clock, + DollarSign, + Flame, + Gift, + Share2, + Target, + Users, + X, +} from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { useParams, useRouter } from "next/navigation"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CommentsSection } from "@/components/comments-section"; +import { ContributionForm } from "@/components/contribution-form"; +import { EntryForm } from "@/components/entry-form"; +import Link from "next/link"; +import { Progress } from "@/components/ui/progress"; +import { UserRankBadge } from "@/components/user-rank-badge"; +import { useAppContext } from "@/contexts/app-context"; +import { useEffect, useState } from "react"; + +export default function PostPage() { + const { user, burnPost } = useAppContext(); + const [post, setPost] = useState(null); + const params = useParams(); + const postId = params.postId as string; + const contextPost = posts.find((p) => p.id === postId); + const [fetchedPost, setFetchedPost] = useState(null); + const [isLoadingPost, setIsLoadingPost] = useState(false); + const [isBurned, setIsBurned] = useState(false); + const [showStats, setShowStats] = useState(false); + const [showEntryForm, setShowEntryForm] = useState(false); + const [selectedMediaIndex, setSelectedMediaIndex] = useState( + null, + ); + const [showContributionForm, setShowContributionForm] = useState(false); + + const router = useRouter(); + const normalizeApiPost = (apiPost: any) => { + if (!apiPost) return null; + + const normalizedType = apiPost.type; + const normalizedStatus = + apiPost.status === "open" || apiPost.status === "in_progress" + ? "active" + : apiPost.status; + const fallbackUsername = + apiPost.user?.name?.toLowerCase()?.replace(/\s+/g, "") || "user"; + + return { + ...apiPost, + type: normalizedType, + status: normalizedStatus, + createdAt: apiPost.createdAt ? new Date(apiPost.createdAt) : new Date(), + updatedAt: apiPost.updatedAt ? new Date(apiPost.updatedAt) : new Date(), + endDate: apiPost.endsAt ? new Date(apiPost.endsAt) : undefined, + burnCount: apiPost.burnCount ?? 0, + shareCount: apiPost.shareCount ?? 0, + commentCount: apiPost.commentCount ?? 0, + likesCount: apiPost.likesCount ?? apiPost._count?.interactions ?? 0, + entriesCount: apiPost.entriesCount ?? apiPost._count?.entries ?? 0, + author: { + id: apiPost.user?.id ?? apiPost.userId, + name: apiPost.user?.name ?? "Unknown User", + username: apiPost.user?.username ?? fallbackUsername, + avatarUrl: apiPost.user?.avatarUrl, + rank: apiPost.user?.rank, + }, + }; + }; + + useEffect(() => { + let ignore = false; + + const loadPost = async () => { + if (contextPost) { + setFetchedPost(null); + setIsLoadingPost(false); + return; + } + + setIsLoadingPost(true); + + try { + const response = await fetch(`/api/posts/${postId}`, { + cache: "no-store", + }); + + if (!response.ok) { + if (!ignore) { + setFetchedPost(null); + } + return; + } + + const result = await response.json(); + if (!ignore) { + setFetchedPost(normalizeApiPost(result?.data)); + } + } catch (error) { + if (!ignore) { + setFetchedPost(null); + } + } finally { + if (!ignore) { + setIsLoadingPost(false); + } + } + }; + + loadPost(); + + return () => { + ignore = true; + }; + }, [contextPost, postId]); + + const post = contextPost ?? fetchedPost; + const canInteract = user && user.id !== post?.userId; + + if (!post && isLoadingPost) { + return null; + } + + const handleSelectWinners = async (method: 'random' | 'manual', winnerIds?: string[]) => { + if (!confirm(`Are you sure you want to select winners using ${method} method?`)) return; + try { + const res = await fetch(`/api/posts/${post.id}/select-winners`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method, winnerIds }) + }); + if (res.ok) { + loadPost(); + } else { + const errorData = await res.json(); + alert(errorData.error || 'Failed to select winners'); + } + } catch(error) { + console.error(error); + alert('Failed to select winners'); + } + }; + + if (!post) { + return ( +
    +

    Post Not Found

    +

    + The post you're looking for doesn't exist or has been removed. +

    + + + +
    + ); + } + + const getStatusColor = (status: string) => { + switch (status) { + case "active": + return "bg-green-50 text-green-700 border-green-200 dark:bg-green-950/20 dark:text-green-400 dark:border-green-800"; + case "completed": + return "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/20 dark:text-blue-400 dark:border-blue-800"; + case "cancelled": + return "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/20 dark:text-red-400 dark:border-red-800"; + case "expired": + return "bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700"; + default: + return "bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700"; + } + }; + + const getProgressPercentage = () => { + if ( + post.type === "request" && + post.targetAmount && + post.currentAmount + ) { + return Math.min((post.currentAmount / post.targetAmount) * 100, 100); + } + return 0; + }; + + const handleAuthRequiredAction = (action: () => void) => { + if (!user) { + router.push("/login"); + return; + } + action(); + }; + + const handleBurn = (e: React.MouseEvent) => { + e.stopPropagation(); + handleAuthRequiredAction(() => { + if (!isBurned) { + burnPost(post.id); + setIsBurned(true); + } + }); + }; + + const handleShare = (e: React.MouseEvent) => { + e.stopPropagation(); + if (navigator.share) { + navigator.share({ + title: post.title, + text: post.description, + url: `${window.location.origin}/post/${post.id}`, + }); + } else { + navigator.clipboard.writeText( + `${window.location.origin}/post/${post.id}`, + ); + } + }; + + const handleStatsClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setShowStats(!showStats); + }; + + const handleCardClick = () => { + router.push(`/post/${post.id}`); + }; + + const handleInteractiveClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + const handleMediaClick = (index: number) => { + if (post.media && post.media.length > 1) { + setSelectedMediaIndex(index); + } + }; + + const handlePrevMedia = () => { + if (selectedMediaIndex !== null && post.media) { + setSelectedMediaIndex( + selectedMediaIndex > 0 ? selectedMediaIndex - 1 : post.media.length - 1, + ); + } + }; + + const handleNextMedia = () => { + if (selectedMediaIndex !== null && post.media) { + setSelectedMediaIndex( + selectedMediaIndex < post.media.length - 1 ? selectedMediaIndex + 1 : 0, + ); + } + }; + + const closeOverlay = () => { + setSelectedMediaIndex(null); + }; + + return ( + <> +
    + {/* Back Button */} + + + +
    +
    +
    +
    +
    +
    + + + + + {post.author.name + .split(" ") + .map((n:any) => n[0]) + .join("")} + + + +
    +
    + + {post.author.name} + + +
    +
    + @{post.author.username} + + {post.createdAt.toLocaleDateString()} +
    +
    +
    +
    + + {post.status} + + + {post.type === "giveaway" ? ( + + ) : ( + + )} + {post.type === "giveaway" ? "Giveaway" : "Help Request"} + +
    +
    +
    + +
    +
    +

    + {post.title} +

    +

    + {post.description} +

    +
    + + {post.media && post.media.length > 0 && ( +
    + {post.media.length === 1 ? ( +
    + {post.media[0].type === "image" ? ( + Post media + ) : ( +
    +
    + )} +
    + ) : ( +
    + {post.media.map((media:any, index:any) => ( +
    handleMediaClick(index)} + > + {media.type === "image" ? ( + {`Post + ) : ( +
    +
    + )} +
    + ))} +
    + )} +
    + )} + + {/* Giveaway Details */} + {post.type === "giveaway" && ( +
    +
    +
    + + + Prize: {post.prizeAmount} {post.currency} + +
    + {post.endDate && ( +
    + + Ends {post.endDate.toLocaleDateString()} +
    + )} +
    + + {post.entryRequirements && ( +
    +

    + Requirements: +

    +
      + {post.entryRequirements.map((req:any, index:any) => ( +
    • + + {req} +
    • + ))} +
    +
    + )} + + {(post.status === "active" || post.status === "open") && ( +
    + + + {!canInteract && post.entries?.length > 0 && ( + + )} +
    + )} + + {post.status === "completed" && post.winners && post.winners.length > 0 && ( +
    +
    + + Giveaway Completed - Winners Announced! +
    +
    + {post.winners.map((winner: any) => ( +
    + + + {winner.user.name?.[0]} + + {winner.user.name} +
    + ))} +
    +
    + )} +
    + )} + + {/* Help Request Details */} + {post.type === "request" && ( +
    +
    +
    + + + Goal: ${post.targetAmount} {post.currency} + +
    + + ${post.currentAmount} raised + +
    + +
    + +
    + + {getProgressPercentage().toFixed(1)}% funded + + + {post.contributions?.length || 0} contributors + +
    +
    + + {post.status === "active" && + (post.currentAmount || 0) < (post.targetAmount || 0) && ( + + )} +
    + )} + + {/* Actions */} +
    +
    + + + + + +
    + + +
    + + {showStats && ( +
    +
    +
    + + + {post.entries?.length || 0} entries + +
    +
    + + + Created {post.createdAt.toLocaleDateString()} + +
    +
    + + {post.type === "giveaway" && ( +
    +
    +
    + Selection:{" "} + {post.selectionType} +
    +
    + Winners:{" "} + {post.winnerCount} +
    +
    + {post.proofRequired && ( +
    + Proof required{" "} + for entry +
    + )} +
    + )} +
    + )} +
    +
    +
    +
    + + {/* All Comments */} + + + {/* Media Overlay */} + {selectedMediaIndex !== null && post.media && ( +
    +
    + {/* Close Button */} + + + {/* Navigation Buttons */} + {post.media.length > 1 && ( + <> + + + + )} + + {/* Media Content */} +
    + {post.media[selectedMediaIndex].type === "image" ? ( + {`Media + ) : ( +
    + + {/* Media Counter */} + {post.media.length > 1 && ( +
    + {selectedMediaIndex + 1} / {post.media.length} +
    + )} +
    +
    + )} +
    + + {/* Entry Form Modal */} + {post.type === "giveaway" && ( + + )} + + {/* Contribution Form Modal */} + {post.type === "request" && ( + + )} + + ); +} diff --git a/app/app/profile/[userId]/page.tsx b/app/app/profile/[userId]/page.tsx new file mode 100644 index 0000000..1b1f7ef --- /dev/null +++ b/app/app/profile/[userId]/page.tsx @@ -0,0 +1,289 @@ +'use client'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Calendar, Gift, Settings, Star } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +import { AchievementsDialog } from '@/components/achievements-dialog'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { FollowListDialog } from '@/components/follow-list-dialog'; +import { PostCard } from '@/components/post-card'; +import { UserRankBadge } from '@/components/user-rank-badge'; +import { useAppContext } from '@/contexts/app-context'; +import { useParams } from 'next/navigation'; +import { useState, useEffect } from 'react'; + +export default function ProfilePage() { + const params = useParams(); + const { user: currentUser } = useAppContext(); + const [profileUser, setProfileUser] = useState(null); + const [userPosts, setUserPosts] = useState([]); + const [showAchievements, setShowAchievements] = useState(false); + const [isFollowing, setIsFollowing] = useState(false); + const [followerCount, setFollowerCount] = useState(0); + const [followingCount, setFollowingCount] = useState(0); + const [showFollowList, setShowFollowList] = useState<{ open: boolean; type: 'followers' | 'following' }>({ + open: false, + type: 'followers', + }); + + const userId = params.userId as string; + //const profileUser = users.find((u) => u.id === userId); + //const userPosts = posts.filter((p) => p.userId === userId); + const isOwnProfile = currentUser?.id === userId; + + const givePosts = userPosts.filter((p) => p.type === 'giveaway').length; + const takePosts = userPosts.filter((p) => p.type === 'request').length; + + useEffect(() => { + const loadProfile = async () => { + try { + const [userRes, postsRes] = await Promise.all([ + fetch(`/api/users/${userId}`), + fetch(`/api/posts?userId=${userId}`), + ]); + + if (userRes.ok) { + const userData = await userRes.json(); + setProfileUser(userData.data); + setIsFollowing(!!userData.data.isFollowing); + setFollowerCount(userData.data._count?.followers || 0); + setFollowingCount(userData.data._count?.followings || 0); + } + + if (postsRes.ok) { + const postsData = await postsRes.json(); + setUserPosts(postsData.data ?? []); + } + } catch (error) { + console.error('Failed to load profile:', error); + } + }; + + if (userId) loadProfile(); + }, [userId]); + + const handleFollowToggle = async () => { + if (!currentUser) return; + + const newIsFollowing = !isFollowing; + setIsFollowing(newIsFollowing); + setFollowerCount((prev) => (newIsFollowing ? prev + 1 : prev - 1)); + + try { + const method = newIsFollowing ? 'POST' : 'DELETE'; + const res = await fetch(`/api/users/${userId}/follow`, { method }); + if (!res.ok) throw new Error('Failed to toggle follow status'); + } catch (error) { + console.error(error); + setIsFollowing(!newIsFollowing); + setFollowerCount((prev) => (newIsFollowing ? prev - 1 : prev + 1)); + } + }; if (!profileUser) { + return ( +
    +
    +

    User not found

    +

    + The user you're looking for doesn't exist. +

    +
    +
    + ); + } + + return ( +
    +
    + {/* Profile Header */} + + +
    + {/* Profile Picture at Top */} +
    + + + + {profileUser.name + .split(' ') + .map((n: string) => n[0]) + .join('')} + + +
    + + {/* Name and Verification */} +
    +
    +

    {profileUser.name}

    + {profileUser.isVerified && ( + + + Verified + + )} + {isOwnProfile && ( + + + + )} +
    +

    + @{profileUser.username} +

    + +
    + + {/* Bio */} +

    + {profileUser.bio} +

    + +
    + + + + + +
    + + {/* Join Date */} +
    + + Joined{' '} + {profileUser.createdAt.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + })} +
    + + {/* Follow Button for Other Users */} + {!isOwnProfile && ( + + )} +
    +
    +
    + + {/* Content Tabs */} + + + Posts ({userPosts.length}) + Gives ({givePosts}) + Takes ({takePosts}) + + + + {userPosts.length > 0 ? ( + userPosts.map((post) => ) + ) : ( + + + +

    No posts yet

    +

    + {isOwnProfile + ? 'Start creating giveaways or help requests!' + : `${profileUser.name} hasn't posted anything yet.`} +

    +
    +
    + )} +
    + + + {userPosts + .filter((p) => p.type === 'giveaway') + .map((post) => ( + + ))} + + + + {userPosts + .filter((p) => p.type === 'request') + .map((post) => ( + + ))} + +
    +
    + + + + setShowFollowList((prev) => ({ ...prev, open }))} + userId={userId} + type={showFollowList.type} + /> +
    + ); +} diff --git a/app/app/settings/page.tsx b/app/app/settings/page.tsx new file mode 100644 index 0000000..5f0ca01 --- /dev/null +++ b/app/app/settings/page.tsx @@ -0,0 +1,422 @@ +'use client'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + ArrowLeft, + Bell, + Camera, + CreditCard, + Loader2, + Moon, + Shield, + Sun, + Trash2, + Wallet, +} from 'lucide-react'; + +import { AuthGuard } from '@/components/auth-guard'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { useAppContext } from '@/contexts/app-context'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import type React from 'react'; +import { useState, useRef } from 'react'; +import { uploadAvatar } from '@/lib/storage'; +import { toast } from 'sonner'; + +export default function SettingsPage() { + const { user, logout, toggleTheme, theme, setCurrentUser } = useAppContext(); + const router = useRouter(); + const avatarInputRef = useRef(null); + + const [formData, setFormData] = useState({ + name: user?.name || '', + username: user?.username || '', + bio: user?.bio || '', + email: user?.email || '', + }); + + const [isSaving, setIsSaving] = useState(false); + + const [notifications, setNotifications] = useState({ + email: true, + push: false, + marketing: false, + }); + + const handleInputChange = ( + e: React.ChangeEvent, + ) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }); + }; + + const handleSaveProfile = async () => { + if (!user?.id) return; + + setIsSaving(true); + try { + const response = await fetch(`/api/users/${user.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: formData.name, + username: formData.username, + bio: formData.bio, + email: formData.email, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + toast.error('Failed to save', { + description: data.error || 'Something went wrong. Please try again.', + }); + return; + } + + // Merge the returned fields back into the current user object so every + // part of the UI (navbar, profile page) reflects the change immediately + // without requiring a full page reload. + setCurrentUser({ ...user, ...data.data }); + + toast.success('Profile updated', { + description: 'Your profile has been saved successfully.', + }); + } catch { + toast.error('Network error', { + description: 'Could not reach the server. Please check your connection.', + }); + } finally { + setIsSaving(false); + } + }; + + const handleAvatarChange = async (file: File) => { + if (!user?.id) return; + + setIsSaving(true); + try { + const avatarUrl = await uploadAvatar(file); + + const response = await fetch(`/api/users/${user.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ avatarUrl }), + }); + + if (!response.ok) throw new Error('Failed to update avatar'); + + const data = await response.json(); + setCurrentUser({ ...user, avatarUrl: data.data.avatarUrl }); + toast.success('Avatar updated successfully'); + } catch (error) { + toast.error('Failed to update avatar'); + } finally { + setIsSaving(false); + } + }; + + const handleDeleteAccount = () => { + if ( + confirm( + 'Are you sure you want to delete your account? This action cannot be undone.', + ) + ) { + logout(); + router.push('/'); + toast.error('Account deleted', { + description: 'Your account has been permanently deleted.', + }); + } + }; + + return ( + +
    + {/* Header */} +
    + + + +

    Settings

    +
    + + {/* Profile Settings */} + + + Profile Information + + Update your public profile information + + + + {/* Avatar */} +
    +
    + + + + {user?.name + ? user.name.split(' ').map((n: string) => n[0]).join('') + : 'U'} + + + {isSaving && ( +
    + +
    + )} +
    +
    + + { + const file = e.target.files?.[0]; + if (file) handleAvatarChange(file); + }} + /> +

    Max 10MB • PNG, JPG, GIF

    +
    +
    + + {/* Form Fields */} +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + +