Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ voting-system/
## 🚀 Quick Start (Local Development)

1. **Clone the repository:**

```bash
git clone https://github.com/Kaushik4141/voting-system.git
cd voting-system
```

2. **Install dependencies:**

```bash
pnpm install
```
Expand All @@ -44,7 +46,6 @@ voting-system/
- Frontend (`apps/web`): Copy `.env.example` to `.env`
- Backend (`apps/api`): Copy `.dev.vars.example` to `.dev.vars`


4. **Start development servers:**
```bash
pnpm dev
Expand All @@ -54,11 +55,25 @@ voting-system/

## 🌐 Self-Deployment & Infrastructure Guide

Planning to deploy your own instance of the Voting System?
Planning to deploy your own instance of the Voting System?

Check out our comprehensive **[Self-Deployment & Infrastructure Guide](./DEPLOYMENT.md)** for detailed instructions on:

- Cloudflare Pages & Workers deployment
- Custom Domain & DNS configuration
- Cloudflare D1 Database migrations
- Clerk Authentication setup & Webhooks user synchronization

### Manual admin promotion (fallback)

If `ADMIN_EMAILS` wasn't set at deploy time:

```bash
npx wrangler d1 execute vote-system --command "UPDATE users SET is_admin = 1 WHERE email = 'user@example.com'"
```

## Admin setup

### First admin (automatic)

Set `ADMIN_EMAILS` as a comma-separated list in your Worker environment:
12 changes: 4 additions & 8 deletions apps/api/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import type { Config } from 'drizzle-kit';
import { defineConfig } from 'drizzle-kit'

export default {
export default defineConfig({
schema: './src/db/schema.ts',
out: './migrations',
driver: 'd1',
dbCredentials: {
wranglerConfigPath: 'wrangler.toml',
dbName: 'voting-system-db',
},
} satisfies Config;
dialect: 'sqlite',
})
14 changes: 10 additions & 4 deletions apps/api/src/controllers/progress.Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@ import type { AppEnv } from '../types'
import { fetchProgress } from '../services/progress.Service'

export const getProgress = async (c: Context<AppEnv>) => {
const userId = c.req.param('userId')
const paramUserId = c.req.param('userId')
const tokenUserId = c.get('userId') // set by requireAuth middleware

if (!userId) {
if (!paramUserId) {
return c.json({ success: false, message: 'userId is required' }, 400)
}

// Reject IDOR: param must match the authenticated user
if (tokenUserId !== paramUserId) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}

try {
const data = await fetchProgress(c.env.DB, userId)
const data = await fetchProgress(c.env.DB, paramUserId)
return c.json(data)
} catch (e: any) {
console.error('Progress error:', e)
return c.json({ success: false, message: 'Internal Server Error' }, 500)
}
}
}
21 changes: 14 additions & 7 deletions apps/api/src/controllers/webhook.Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Webhook } from 'svix'
import type { AppEnv } from '../types'
import { getDb } from '../db/client'
import { users } from '../db/schema'
import { ensureUserExists } from '../utils/user'

export const clerkWebhook = async (c: Context<AppEnv>) => {
const webhookSecret = c.env.CLERK_WEBHOOK_SECRET
Expand All @@ -29,15 +30,21 @@ export const clerkWebhook = async (c: Context<AppEnv>) => {
return c.json({ error: 'Invalid webhook signature' }, 401)
}

if (event.type === 'user.created') {
if (event.type === 'user.created' || event.type === 'user.updated') {
const { id, email_addresses, first_name, last_name } = event.data

const email = email_addresses?.[0]?.email_address ?? null
const name = `${first_name ?? ''} ${last_name ?? ''}`.trim() || null

const db = getDb(c.env.DB)
await db.insert(users).values({ id, email, name }).onConflictDoNothing()
const email = email_addresses?.[0]?.email_address ?? ''
const name = `${first_name ?? ''} ${last_name ?? ''}`.trim() || ''

// Replaces raw insert — now checks ADMIN_EMAILS for first-admin bootstrap
await ensureUserExists(
c.env.DB,
id,
email,
name,
c.env.ADMIN_EMAILS
)
}

return c.json({ success: true })
}
}
9 changes: 8 additions & 1 deletion apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const users = sqliteTable('users', {
name: text('name'),
completed: integer('completed', { mode: 'boolean' }).default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
is_admin:integer('is_admin', { mode: 'boolean' }).default(0),
});

export const items = sqliteTable('items', {
Expand All @@ -21,14 +22,20 @@ export const items = sqliteTable('items', {
qualifiedAvgRating: real('qualified_avg_rating').default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});

export const settings = sqliteTable('settings', {
id: integer('id').primaryKey({ autoIncrement: true }),
key: text('key').unique().notNull(),
value: text('value'),
updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});
export const ratings = sqliteTable('ratings', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
itemId: integer('item_id').notNull().references(() => items.id),
rating: integer('rating').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
},

(table) => ({
unq: uniqueIndex('unique_vote').on(table.userId, table.itemId),
})
Expand Down
10 changes: 6 additions & 4 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Hono } from 'hono'
import { clerkMiddleware } from '@hono/clerk-auth'
import { clerkMiddleware } from '@clerk/hono'
import type { AppEnv } from './types'

import voteRoutes from './routes/vote.route'
Expand All @@ -10,14 +10,17 @@ import resultsRoutes from './routes/results.route'
import userRoutes from './routes/user.route'
import webhookRoutes from './routes/webhook.route'
import { cors } from 'hono/cors'
import adminRoutes from './routes/admin.route'

// Mount under /api/v1/admin
const app = new Hono<AppEnv>()

const port = 8000;

//using cors and clerk middleware
app.use('*', cors())
app.use('*', clerkMiddleware())

app.all('/__clerk/*', async (c) => {
const url = new URL(c.req.url);

Expand Down Expand Up @@ -72,8 +75,6 @@ app.all('/__clerk/*', async (c) => {
// Webhook route must come before clerkMiddleware
app.route('/webhooks', webhookRoutes)

app.use('*', clerkMiddleware())

//protected api group
const api = app.basePath('/api/v1')

Expand All @@ -83,7 +84,8 @@ api.route('/progress', progressRoutes)
api.route('/items', itemsRoutes)
api.route('/results', resultsRoutes)
api.route('/user', userRoutes)

api.route('/admin', adminRoutes)
app.get('/', (c) => c.json({ ok: true }))
export default {
port,
fetch: app.fetch,
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/middleware/adminAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { eq } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/d1'
import { getAuth } from '@clerk/hono'
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../types'
import { users } from '../db/schema'

export const requireAdmin = createMiddleware<AppEnv>(async (c, next) => {
const auth = getAuth(c)

if (!auth?.userId) {
return c.json({ success: false, error: 'Unauthorized' }, 401)
}

const db = drizzle(c.env.DB)
const user = await db
.select()
.from(users)
.where(eq(users.id, auth.userId))
.get()

if (!user || !user.is_admin) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}

c.set('userId', auth.userId)
c.set('isAdmin', true)
await next()
})
21 changes: 19 additions & 2 deletions apps/api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { getAuth } from '@hono/clerk-auth'
import { getAuth } from '@clerk/hono'
import { createMiddleware } from 'hono/factory'
import { createClerkClient } from '@clerk/backend'
import type { AppEnv } from '../types'
import { ensureUserExists } from '../utils/user'

export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
const auth = getAuth(c)
Expand All @@ -9,6 +11,21 @@ export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
return c.json({ success: false, error: 'Unauthorized' }, 401)
}

// FIX: create the client instance
const clerk = createClerkClient({ secretKey: c.env.CLERK_SECRET_KEY })
const clerkUser = await clerk.users.getUser(auth.userId)

const email = clerkUser.emailAddresses[0]?.emailAddress
const name = `${clerkUser.firstName || ''} ${clerkUser.lastName || ''}`.trim()

await ensureUserExists(
c.env.DB,
auth.userId,
email || '',
name,
c.env.ADMIN_EMAILS
)

c.set('userId', auth.userId)
await next()
})
})
Loading
Loading