diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..919396a3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,85 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [Unreleased]
+
+### Added - Saved Repos Feature
+
+#### Frontend
+- **Zustand Store**: Created `useSavedProjectsStore` with localStorage persistence
+ - Stores saved repositories with key `oss_saved_repos_v1`
+ - Actions: `addProject`, `removeProject`, `toggleProject`, `clearAllSaved`, `setAll`, `isSaved`
+ - Automatic persistence across page reloads
+
+- **UI Components**:
+ - `SaveToggle`: Star icon button in project rows to save/unsave repositories
+ - `SavedProjectsPanel`: Side panel for managing saved repos
+ - Export saved repos to JSON file
+ - Import saved repos from JSON file
+ - Clear all saved repos with confirmation
+ - View all saved repos with metadata
+
+- **Projects Table**: Added "Save" column as first column in OSS Projects table
+- **Header Button**: Added "Saved Projects" button with count badge in projects page header
+
+#### Backend
+- **Database**: Added `saved_repos` JSONB column to `User` model (default: `[]`)
+- **Service Layer**: Created `savedReposService` with:
+ - `getSavedRepos`: Retrieve user's saved repos
+ - `mergeSavedRepos`: Merge local and server repos with conflict resolution
+ - `updateSavedRepos`: Update saved repos with add/remove/replace actions
+ - Maximum 100 saved repos per user enforcement
+
+- **API Endpoints** (tRPC):
+ - `user.getSavedRepos`: Get user's saved repos (protected, feature flag: `FEATURE_SAVED_REPOS_DB`)
+ - `user.updateSavedRepos`: Update saved repos with merge logic (protected, feature flag: `FEATURE_SAVED_REPOS_DB`)
+ - Conflict resolution: Newer `savedAt` timestamp wins
+
+#### Shared Types
+- Created `SavedRepo` type definition in `@opensox/shared`
+- Created `SavedReposAction` and `SavedReposUpdateInput` types
+
+### Configuration
+- **Feature Flag**: `FEATURE_SAVED_REPOS_DB` - Enable/disable database sync (default: disabled)
+ - When disabled: Client-only mode with localStorage
+ - When enabled: Full sync across devices with merge logic
+
+### Migration
+- Migration file: `add_saved_repos` - Adds `saved_repos` JSONB column to User table
+
+---
+
+## How to Use
+
+### For Users
+1. Navigate to `/dashboard/projects`
+2. Click "Find projects" to search for repositories
+3. Click the star icon on any project to save it
+4. Click "Saved Projects" button to view/manage saved repos
+5. Export/import saved repos as JSON for backup
+
+### For Developers
+1. **Client-only mode** (default): Works out of the box with localStorage
+2. **Database sync mode**: Set `FEATURE_SAVED_REPOS_DB=true` in `apps/api/.env`
+3. Run migration: `cd apps/api && npx prisma migrate dev`
+4. Restart API server
+
+### API Usage (when feature flag enabled)
+```typescript
+// Get saved repos
+const savedRepos = await trpc.user.getSavedRepos.query();
+
+// Add repos
+await trpc.user.updateSavedRepos.mutate({
+ action: 'add',
+ repos: [{ id: '123', name: 'repo', url: 'https://...', savedAt: new Date().toISOString() }]
+});
+
+// Sync with merge
+await trpc.user.updateSavedRepos.mutate({
+ action: 'replace',
+ repos: serverRepos,
+ localRepos: clientRepos // Will merge and resolve conflicts
+});
+```
diff --git a/Dockerfile.dev b/Dockerfile.dev
new file mode 100644
index 00000000..d1a1753d
--- /dev/null
+++ b/Dockerfile.dev
@@ -0,0 +1,32 @@
+# Base stage with pnpm
+FROM node:20-alpine AS base
+RUN npm install -g pnpm
+WORKDIR /app
+
+# API development
+FROM base AS api
+COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
+COPY apps/api/package.json ./apps/api/
+COPY packages/shared/package.json ./packages/shared/
+RUN pnpm install
+COPY packages/shared ./packages/shared
+COPY apps/api ./apps/api
+WORKDIR /app/packages/shared
+RUN pnpm run build
+WORKDIR /app/apps/api
+RUN pnpm exec prisma generate
+EXPOSE 8080
+CMD ["pnpm", "run", "dev"]
+
+# Web development
+FROM base AS web
+COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
+COPY apps/web/package.json ./apps/web/
+COPY packages/shared/package.json ./packages/shared/
+COPY packages/ui/package.json ./packages/ui/
+RUN pnpm install
+COPY packages ./packages
+COPY apps/web ./apps/web
+WORKDIR /app/apps/web
+EXPOSE 3000
+CMD ["pnpm", "run", "dev"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..b14bad58
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,128 @@
+.PHONY: help start stop restart logs clean reset setup dev test status health migrate seed studio shell-api shell-db dev-local logs-api logs-web logs-db migrate-dev
+
+# Default target - show help
+help:
+ @echo ""
+ @echo "========================================================================"
+ @echo " Opensox Development CLI "
+ @echo "========================================================================"
+ @echo ""
+ @echo " GETTING STARTED:"
+ @echo " make setup - First-time setup wizard (run this first!)"
+ @echo " make start - Start all services (DB, API, Web)"
+ @echo ""
+ @echo " SERVICE MANAGEMENT:"
+ @echo " make stop - Stop all services"
+ @echo " make restart - Restart all services"
+ @echo " make logs - View logs from all services"
+ @echo " make status - Check service health"
+ @echo ""
+ @echo " DATABASE:"
+ @echo " make migrate - Run database migrations"
+ @echo " make seed - Seed database with initial data"
+ @echo " make studio - Open Prisma Studio (GUI)"
+ @echo " make reset - Stop services and reset database (DELETES DATA)"
+ @echo ""
+ @echo " DEBUGGING:"
+ @echo " make shell-api - Open shell in API container"
+ @echo " make shell-db - Open psql shell in database"
+ @echo " make logs-api - View API logs only"
+ @echo " make logs-web - View Web logs only"
+ @echo ""
+ @echo " CLEANUP:"
+ @echo " make clean - Remove containers, volumes, and build cache"
+ @echo ""
+ @echo "========================================================================"
+ @echo ""
+
+# First-time setup
+setup:
+ @echo "Starting Opensox setup wizard..."
+ @chmod +x ./scripts/setup.sh 2>/dev/null || true
+ @./scripts/setup.sh
+
+# Start all services
+start:
+ @echo "Starting all services..."
+ docker compose up -d
+ @$(MAKE) --no-print-directory health
+ @echo ""
+ @echo "Services started successfully!"
+ @echo " Frontend: http://localhost:3000"
+ @echo " API: http://localhost:8080"
+ @echo ""
+
+# Stop all services
+stop:
+ @echo "Stopping all services..."
+ docker compose down
+
+# Restart services
+restart: stop start
+
+# View logs
+logs:
+ docker compose logs -f
+
+logs-api:
+ docker compose logs -f api
+
+logs-web:
+ docker compose logs -f web
+
+logs-db:
+ docker compose logs -f postgres
+
+# Check status
+status:
+ @docker compose ps
+
+# Health check
+health:
+ @echo "Waiting for services to be healthy..."
+ @sleep 3
+ @docker compose ps --format "table {{.Name}}\t{{.Status}}" | head -10
+
+# Database operations
+migrate:
+ @echo "Running database migrations..."
+ docker compose exec api pnpm exec prisma migrate deploy
+
+migrate-dev:
+ docker compose exec api pnpm exec prisma migrate dev
+
+seed:
+ @echo "Seeding database..."
+ docker compose exec api pnpm exec prisma db seed
+
+studio:
+ @echo "Opening Prisma Studio..."
+ docker compose exec api pnpm exec prisma studio
+
+# Reset database
+reset:
+ @echo "WARNING: This will delete all data. Are you sure? [y/N] " && read ans && [ $${ans:-N} = y ]
+ docker compose down -v
+ @$(MAKE) --no-print-directory start
+ @$(MAKE) --no-print-directory migrate
+ @$(MAKE) --no-print-directory seed
+
+# Clean everything
+clean:
+ @echo "Cleaning up..."
+ docker compose down -v --rmi local --remove-orphans
+ rm -rf apps/api/node_modules apps/web/node_modules
+ rm -rf apps/api/dist apps/web/.next
+
+# Shell access
+shell-api:
+ docker compose exec api sh
+
+shell-db:
+ docker compose exec postgres psql -U opensox -d opensox
+
+# Development without Docker (uses local Node)
+dev-local:
+ @echo "Starting local development..."
+ pnpm install
+ pnpm run dev
diff --git a/README.md b/README.md
index d51d2650..5362caa7 100644
--- a/README.md
+++ b/README.md
@@ -38,234 +38,166 @@ We love our contributors! Hereβs how you can contribute:
- [Open an issue](https://github.com/apsinghdev/opensox/issues) if you believe youβve encountered a bug.
- Make a [pull request](https://github.com/apsinghdev/opensox/pulls) to add new features, improve quality of life, or fix bugs.
-### Setting up locally
+## Quick Start (Docker) π³
-Opensox AI's stack consists of the following elements:
+Get up and running in **3 steps**:
-- A backend API built with tRPC and Express.js
-- A frontend written in Next.js and TypeScript
-- A PostgreSQL database
-- A Redis database (in process)
-- What's for AI? Coming very soonβ¦
-
-#### Prerequisites
-
-Opensox needs [TypeScript](https://www.typescriptlang.org/download/) and [Node.js >= 18](https://nodejs.org/en/download/package-manager) installations.
-
-## Setup environment variables
-
-Create environment files for both the backend and the frontend before running the apps.
-
-### Backend (`apps/api/.env`)
+### Prerequisites
-Copy the example environment file and update it with your values:
+- [Docker](https://docs.docker.com/get-docker/) installed and running
+- [Make](https://www.gnu.org/software/make/) (pre-installed on Mac/Linux)
+### 1. Clone the repository
```bash
-cd apps/api
-cp .env.example .env
+git clone https://github.com/apsinghdev/opensox.git
+cd opensox
```
-Then edit `apps/api/.env` and fill in the required values:
-
+### 2. Run setup
```bash
-# Required
-DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/opensox?schema=public"
-JWT_SECRET="replace-with-a-strong-random-secret"
-
-# Optional (good defaults shown)
-PORT=8080
-CORS_ORIGINS=http://localhost:3000
-NODE_ENV=development
-
-# Optional but needed for GitHub queries to work
-# Generate a classic token with "public_repo" access at https://github.com/settings/tokens
-GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-```
-
-Notes:
-- DATABASE_URL: point this to your PostgreSQL instance. Example for local Postgres is shown above.
-- JWT_SECRET: generate one, e.g. `openssl rand -base64 32` or `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
-- CORS_ORIGINS: comma-separated list of allowed origins. Keep `http://localhost:3000` for local web app.
-
-### Frontend (`apps/web/.env.local`)
-
-Create a file at `apps/web/.env.local` with:
+# Mac/Linux
+make setup
-```bash
-# Required
-NEXT_PUBLIC_API_URL="http://localhost:8080"
-GOOGLE_CLIENT_ID="your-google-oauth-client-id"
-GOOGLE_CLIENT_SECRET="your-google-oauth-client-secret"
-NEXTAUTH_SECRET="replace-with-a-strong-random-secret"
-
-# Recommended for production (optional for local dev)
-NEXTAUTH_URL="http://localhost:3000"
-
-# Optional analytics (PostHog)
-# If you don't use PostHog, you can omit these
-# NEXT_PUBLIC_POSTHOG_KEY="phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
-# NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" # or https://app.posthog.com
+# Windows (PowerShell)
+.\setup.ps1
```
-Notes:
-- NEXT_PUBLIC_API_URL must point to the backend base URL; the frontend calls `${NEXT_PUBLIC_API_URL}/api/...` for auth and data.
-- Google OAuth: create credentials in Google Cloud Console (OAuth 2.0 Client ID), add Authorized redirect URIs for NextAuth (e.g., `http://localhost:3000/api/auth/callback/google`).
-- NEXTAUTH_SECRET: generate one just like `JWT_SECRET`. Itβs used by NextAuth; it can be different from the backend secret.
-
-After creating these files, restart your dev servers so changes take effect.
+This will automatically:
+- β Create environment files from templates
+- β Generate secure secrets
+- β Start all services (Database, API, Web)
+- β Run database migrations
+- β Seed initial data
+### 3. Configure Google OAuth (Required for login)
-## Database setup (migrations and seed)
+1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
+2. Create a new project (or use existing)
+3. Configure OAuth consent screen
+4. Create OAuth 2.0 Client ID (Web application)
+5. Add redirect URI: `http://localhost:3000/api/auth/callback/google`
+6. Copy credentials to `apps/web/.env.local`:
+ ```
+ GOOGLE_CLIENT_ID="your-client-id"
+ GOOGLE_CLIENT_SECRET="your-client-secret"
+ ```
+7. Restart: `make restart`
-Run these steps once your `DATABASE_URL` is set:
-
-```bash
-cd apps/api
+**Done! π** Visit http://localhost:3000
-# Generate Prisma client (optional if already generated)
-npx prisma generate
+---
-# Apply migrations locally
-npx prisma migrate dev --name init
-```
+## Common Commands
-Seed initial data so features relying on it work correctly:
+| Command | Description |
+|---------|-------------|
+| `make start` | Start all services |
+| `make stop` | Stop all services |
+| `make logs` | View live logs |
+| `make status` | Check service health |
+| `make reset` | Reset database (deletes data) |
+| `make studio` | Open Prisma Studio (DB GUI) |
+| `make help` | Show all available commands |
-- Option A (recommended): use Prisma Studio to insert the initial row
+---
- ```bash
- npx prisma studio
- ```
+## Troubleshooting
- Then open the `QueryCount` model and create a row with:
- - `id`: 1
- - `total_queries`: 0
+
+π΄ "Cannot connect to Docker daemon"
-- Option B: insert directly via SQL (replace the connection string as needed)
+Make sure Docker Desktop is running.
+
- ```bash
- psql "postgresql://USER:PASSWORD@localhost:5432/opensox" \
- -c "INSERT INTO \"QueryCount\" (id, total_queries) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;"
- ```
-
-
-## Setup environment
-
-1. Fork and clone the opensox repo
+
+π΄ "Port 3000/8080 is already in use"
+Stop the process using the port:
```bash
-git clone https://github.com/[your-github-username]/opensox.git
-```
+# Find process (Mac/Linux)
+lsof -i :3000
-2. `cd` into `opensox/apps/web` and install dependencies
-
-```bash
-pnpm install
+# Windows PowerShell
+netstat -ano | findstr :3000
```
+
-Now run the dev server:
+
+π΄ "OAuth error: redirect_uri_mismatch"
-```bash
-pnpm run dev
-```
-
-Congrats! Your frontend is running on `localhost:3000`.
+Ensure your Google OAuth redirect URI exactly matches:
+`http://localhost:3000/api/auth/callback/google`
+
-3. `cd` into `opensox/apps/api` and install dependencies
+
+π΄ Database connection errors
+Reset the database:
```bash
-npm install
+make reset
```
+
-Now run the server:
-
-```bash
-pnpm run dev
-```
+---
-Voila! Your API server is running on `localhost:4000`.
+
+
Manual Setup (without Docker)
-Now you can access your app at `http://localhost:3000`.
+### Prerequisites
-## Running the API with Docker
+Opensox needs [TypeScript](https://www.typescriptlang.org/download/) and [Node.js >= 18](https://nodejs.org/en/download/package-manager) installations.
-Alternatively, you can run the API server using Docker. A `Dockerfile` is provided in the root directory.
+### Setup environment variables
-### Prerequisites
+Create environment files for both the backend and the frontend before running the apps.
-- [Docker](https://docs.docker.com/get-docker/) installed on your machine
+#### Backend (`apps/api/.env`)
-### Building and Running
+```bash
+cd apps/api
+cp .env.example .env
+```
-1. Make sure you have your `.env` file set up in `apps/api/.env`. You can copy from `.env.example` (see [Backend environment variables](#backend-appsapienv) section above)
+Edit `apps/api/.env` and fill in:
+- `DATABASE_URL` - Your PostgreSQL connection string
+- `JWT_SECRET` - Generate with `openssl rand -base64 32`
-2. From the root directory, build the Docker image:
+#### Frontend (`apps/web/.env.local`)
```bash
-docker build -t opensox-api .
+cd apps/web
+cp .env.example .env.local
```
-3. Run the container with your environment variables:
+Edit `apps/web/.env.local` and fill in your Google OAuth credentials.
+
+### Database setup
```bash
-docker run -p 4000:4000 \
- --env-file apps/api/.env \
- opensox-api
+cd apps/api
+npx prisma generate
+npx prisma migrate dev --name init
+npx prisma db seed
```
-Or if you prefer to pass environment variables individually:
+### Run the servers
```bash
-docker run -p 4000:4000 \
- -e DATABASE_URL="postgresql://USER:PASSWORD@host.docker.internal:5432/opensox?schema=public" \
- -e JWT_SECRET="your-secret" \
- -e PORT=4000 \
- opensox-api
-```
+# Terminal 1 - API
+cd apps/api
+pnpm install
+pnpm run dev
-**Note:** When using Docker, if your database is running on your host machine (not in a container), use `host.docker.internal` instead of `localhost` in your `DATABASE_URL`.
-
-Your API server will be available at `http://localhost:4000`.
-
-### Using Docker Compose (Optional)
-
-For a complete setup with PostgreSQL, you can create a `docker-compose.yml` file:
-
-```yaml
-version: '3.8'
-services:
- postgres:
- image: postgres:15
- environment:
- POSTGRES_USER: opensox
- POSTGRES_PASSWORD: opensox
- POSTGRES_DB: opensox
- ports:
- - "5432:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
-
- api:
- build: .
- ports:
- - "4000:4000"
- environment:
- DATABASE_URL: postgresql://opensox:opensox@postgres:5432/opensox?schema=public
- JWT_SECRET: your-secret-key
- PORT: 4000
- NODE_ENV: production
- depends_on:
- - postgres
-
-volumes:
- postgres_data:
+# Terminal 2 - Web
+cd apps/web
+pnpm install
+pnpm run dev
```
-Then run:
+Frontend: http://localhost:3000 | API: http://localhost:8080
+
+
-```bash
-docker-compose up -d
-```
## Our contributors
diff --git a/apps/api/package.json b/apps/api/package.json
index 0c6722ac..44255189 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -14,6 +14,9 @@
"author": "Ajeet Pratpa Singh",
"license": "ISC",
"packageManager": "pnpm@10.11.0",
+ "prisma": {
+ "seed": "tsx prisma/seed.ts"
+ },
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
@@ -38,8 +41,5 @@
"superjson": "^2.2.5",
"zeptomail": "^6.2.1",
"zod": "^4.1.9"
- },
- "prisma": {
- "seed": "tsx prisma/seed.ts"
}
-}
+}
\ No newline at end of file
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 2bf9695f..e18e8886 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -39,6 +39,7 @@ model User {
createdAt DateTime @default(now())
lastLogin DateTime @updatedAt
completedSteps Json?
+ saved_repos Json @default("[]")
accounts Account[]
payments Payment[]
subscriptions Subscription[]
diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts
index 1f6b186c..6fd888b8 100644
--- a/apps/api/prisma/seed.ts
+++ b/apps/api/prisma/seed.ts
@@ -62,25 +62,25 @@ async function main() {
const premiumSubscription = existingSubscription
? await prisma.subscription.update({
- where: { id: existingSubscription.id },
- data: {
- planId: testPlan.id,
- status: 'active',
- startDate: new Date(),
- endDate: new Date(Date.now() + MILLIS_PER_YEAR), // 1 year from now
- autoRenew: true,
- },
- })
+ where: { id: existingSubscription.id },
+ data: {
+ planId: testPlan.id,
+ status: 'active',
+ startDate: new Date(),
+ endDate: new Date(Date.now() + MILLIS_PER_YEAR), // 1 year from now
+ autoRenew: true,
+ },
+ })
: await prisma.subscription.create({
- data: {
- userId: premiumUser.id,
- planId: testPlan.id,
- status: 'active',
- startDate: new Date(),
- endDate: new Date(Date.now() + MILLIS_PER_YEAR), // 1 year from now
- autoRenew: true,
- },
- });
+ data: {
+ userId: premiumUser.id,
+ planId: testPlan.id,
+ status: 'active',
+ startDate: new Date(),
+ endDate: new Date(Date.now() + MILLIS_PER_YEAR), // 1 year from now
+ autoRenew: true,
+ },
+ });
console.log('β Created/updated premium subscription');
// Create test payment
@@ -127,4 +127,3 @@ main()
.finally(async () => {
await prisma.$disconnect();
});
-
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index f1925322..1dc676ad 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -98,6 +98,11 @@ app.get("/test", apiLimiter, (req: Request, res: Response) => {
res.status(200).json({ status: "ok", message: "Test endpoint is working" });
});
+// Health check endpoint for Docker
+app.get("/health", (req: Request, res: Response) => {
+ res.status(200).json({ status: "ok", timestamp: new Date().toISOString() });
+});
+
// Slack Community Invite Endpoint (Protected)
app.get("/join-community", apiLimiter, async (req: Request, res: Response) => {
try {
diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts
index 94205d01..2ede16d5 100644
--- a/apps/api/src/routers/user.ts
+++ b/apps/api/src/routers/user.ts
@@ -1,5 +1,6 @@
import { router, publicProcedure, protectedProcedure } from "../trpc.js";
import { userService } from "../services/user.service.js";
+import { savedReposService } from "../services/savedRepos.service.js";
import { z } from "zod";
export const userRouter = router({
@@ -34,5 +35,81 @@ export const userRouter = router({
userId,
input.completedSteps
);
+ }),
+
+ // get user's saved repos (feature flag: FEATURE_SAVED_REPOS_DB)
+ getSavedRepos: protectedProcedure.query(async ({ ctx }: any) => {
+ if (process.env.FEATURE_SAVED_REPOS_DB !== "true") {
+ return [];
+ }
+ const userId = ctx.user.id;
+ return await savedReposService.getSavedRepos(ctx.db.prisma, userId);
}),
+
+ // update user's saved repos with merge logic (feature flag: FEATURE_SAVED_REPOS_DB)
+ updateSavedRepos: protectedProcedure
+ .input(
+ z.object({
+ action: z.enum(["add", "remove", "replace"]),
+ repos: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ url: z.string(),
+ language: z.string().optional(),
+ popularity: z.enum(["low", "medium", "high"]).optional(),
+ competitionScore: z.number().optional(),
+ savedAt: z.string(),
+ meta: z.record(z.string(), z.unknown()).optional(),
+ })
+ ),
+ localRepos: z
+ .array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ url: z.string(),
+ language: z.string().optional(),
+ popularity: z.enum(["low", "medium", "high"]).optional(),
+ competitionScore: z.number().optional(),
+ savedAt: z.string(),
+ meta: z.record(z.string(), z.unknown()).optional(),
+ })
+ )
+ .optional(),
+ })
+ )
+ .mutation(async ({ ctx, input }: any) => {
+ if (process.env.FEATURE_SAVED_REPOS_DB !== "true") {
+ throw new Error("Saved repos sync is not enabled");
+ }
+
+ const userId = ctx.user.id;
+
+ // If localRepos provided, merge with server repos
+ if (input.localRepos && input.action === "replace") {
+ const serverRepos = await savedReposService.getSavedRepos(
+ ctx.db.prisma,
+ userId
+ );
+ const merged = savedReposService.mergeSavedRepos(
+ input.localRepos,
+ serverRepos
+ );
+ return await savedReposService.updateSavedRepos(
+ ctx.db.prisma,
+ userId,
+ "replace",
+ merged
+ );
+ }
+
+ // Otherwise, perform the requested action
+ return await savedReposService.updateSavedRepos(
+ ctx.db.prisma,
+ userId,
+ input.action,
+ input.repos
+ );
+ }),
});
diff --git a/apps/api/src/services/savedRepos.service.ts b/apps/api/src/services/savedRepos.service.ts
new file mode 100644
index 00000000..adaf2c52
--- /dev/null
+++ b/apps/api/src/services/savedRepos.service.ts
@@ -0,0 +1,115 @@
+import type { PrismaClient } from "@prisma/client";
+import type { ExtendedPrismaClient } from "../prisma.js";
+import type { SavedRepo } from "@opensox/shared";
+
+export const savedReposService = {
+ /**
+ * Get user's saved repos
+ */
+ async getSavedRepos(
+ prisma: ExtendedPrismaClient | PrismaClient,
+ userId: string
+ ): Promise {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { saved_repos: true },
+ });
+
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ const savedRepos = user.saved_repos as SavedRepo[] | null;
+ return savedRepos || [];
+ },
+
+ /**
+ * Merge local and server saved repos
+ * Resolves conflicts by keeping the newer version (based on savedAt timestamp)
+ */
+ mergeSavedRepos(local: SavedRepo[], server: SavedRepo[]): SavedRepo[] {
+ const merged = new Map();
+
+ // Add all server repos
+ for (const repo of server) {
+ merged.set(repo.id, repo);
+ }
+
+ // Add or update with local repos (newer wins)
+ for (const repo of local) {
+ const existing = merged.get(repo.id);
+ if (!existing || new Date(repo.savedAt) > new Date(existing.savedAt)) {
+ merged.set(repo.id, repo);
+ }
+ }
+
+ return Array.from(merged.values()).sort(
+ (a, b) =>
+ new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime()
+ );
+ },
+
+ /**
+ * Update user's saved repos
+ */
+ async updateSavedRepos(
+ prisma: ExtendedPrismaClient | PrismaClient,
+ userId: string,
+ action: "add" | "remove" | "replace",
+ repos: SavedRepo[]
+ ): Promise {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { saved_repos: true },
+ });
+
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ let currentRepos = (user.saved_repos as SavedRepo[]) || [];
+ let updatedRepos: SavedRepo[];
+
+ switch (action) {
+ case "add": {
+ // Add new repos, skip duplicates
+ const existingIds = new Set(currentRepos.map((r) => r.id));
+ const newRepos = repos.filter((r) => !existingIds.has(r.id));
+ updatedRepos = [...currentRepos, ...newRepos];
+ break;
+ }
+
+ case "remove": {
+ // Remove repos by ID
+ const removeIds = new Set(repos.map((r) => r.id));
+ updatedRepos = currentRepos.filter((r) => !removeIds.has(r.id));
+ break;
+ }
+
+ case "replace": {
+ // Replace entire list (for sync)
+ updatedRepos = repos;
+ break;
+ }
+
+ default:
+ throw new Error(`Invalid action: ${action}`);
+ }
+
+ // Enforce maximum 100 saved repos
+ if (updatedRepos.length > 100) {
+ throw new Error("Maximum 100 saved repos allowed");
+ }
+
+ // Update database
+ const updated = await prisma.user.update({
+ where: { id: userId },
+ data: {
+ saved_repos: updatedRepos as any, // Cast to satisfy Prisma's Json type
+ },
+ select: { saved_repos: true },
+ });
+
+ return (updated.saved_repos as SavedRepo[]) || [];
+ },
+};
diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json
index aa78dc6f..0a5e5e93 100644
--- a/apps/api/tsconfig.json
+++ b/apps/api/tsconfig.json
@@ -4,7 +4,6 @@
// File Layout
"rootDir": "./src",
"outDir": "./dist",
-
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
@@ -13,16 +12,13 @@
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
-
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
-
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
-
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
@@ -30,7 +26,6 @@
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
-
// Recommended Options
"strict": true,
"jsx": "react-jsx",
@@ -48,4 +43,4 @@
"node_modules",
"dist"
]
-}
+}
\ No newline at end of file
diff --git a/apps/web/.env.example b/apps/web/.env.example
new file mode 100644
index 00000000..d39fe2a9
--- /dev/null
+++ b/apps/web/.env.example
@@ -0,0 +1,27 @@
+# ============================================================
+# OPENSOX WEB CONFIGURATION
+# ============================================================
+# Copy this file to .env.local and fill in the values
+
+# -------------------- REQUIRED --------------------
+# API Connection (matches the docker-compose exposed port)
+NEXT_PUBLIC_API_URL="http://localhost:8080"
+
+# NextAuth Configuration
+# Generate with: openssl rand -base64 32
+NEXTAUTH_SECRET="replace-with-a-strong-random-secret"
+NEXTAUTH_URL="http://localhost:3000"
+
+# -------------------- GOOGLE OAUTH (REQUIRED FOR LOGIN) --------------------
+# Create OAuth credentials at: https://console.cloud.google.com/apis/credentials
+# 1. Create a new project (or use existing)
+# 2. Configure OAuth consent screen (external, testing mode is fine)
+# 3. Create OAuth 2.0 Client ID (Web application)
+# 4. Add authorized redirect URI: http://localhost:3000/api/auth/callback/google
+GOOGLE_CLIENT_ID="your-google-oauth-client-id"
+GOOGLE_CLIENT_SECRET="your-google-oauth-client-secret"
+
+# -------------------- OPTIONAL --------------------
+# PostHog Analytics (leave commented out if not using)
+# NEXT_PUBLIC_POSTHOG_KEY="phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+# NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 90975c82..4e968fb7 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -31,6 +31,7 @@ yarn-error.log*
# env files (can opt-in for commiting if needed)
.env*
+!.env.example
# vercel
.vercel
diff --git a/apps/web/src/components/dashboard/ProjectsContainer.tsx b/apps/web/src/components/dashboard/ProjectsContainer.tsx
index 80e380ac..58211e25 100644
--- a/apps/web/src/components/dashboard/ProjectsContainer.tsx
+++ b/apps/web/src/components/dashboard/ProjectsContainer.tsx
@@ -16,6 +16,10 @@ import Image from "next/image";
import { useFilterStore } from "@/store/useFilterStore";
import { usePathname } from "next/navigation";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
+import SaveToggle from "./SaveToggle";
+import SavedProjectsPanel from "./SavedProjectsPanel";
+import { useSavedProjectsStore } from "@/store/useSavedProjectsStore";
+import { useState } from "react";
type ProjectsContainerProps = { projects: DashboardProjectsProps[] };
@@ -42,6 +46,7 @@ const getColor = (c?: string) =>
languageColors[(c || "").toLowerCase()] || "bg-gray-200/10 text-gray-300";
const tableColumns = [
+ "Save",
"Project",
"Issues",
"Language",
@@ -57,6 +62,8 @@ export default function ProjectsContainer({
const pathname = usePathname();
const { projectTitle } = useProjectTitleStore();
const { setShowFilters } = useFilterStore();
+ const { savedProjects } = useSavedProjectsStore();
+ const [showSavedPanel, setShowSavedPanel] = useState(false);
const isProjectsPage = pathname === "/dashboard/projects";
return (
@@ -66,12 +73,39 @@ export default function ProjectsContainer({
{projectTitle}
{isProjectsPage && (
-
+