TaskBoard is a full-stack personal task manager built as a take-home exercise for the Gatherly Engineering team. Users sign in with Google or email/password, manage a private task list with priorities and due dates, and stay authenticated across sessions, all backed by a real PostgreSQL database and secured with JWT.
Built by Ramithachowdary · Submitted May 2026
Deployed URL : Visit TaskBoard
Note: Google OAuth callbacks are still served from the original Render deployment. OTP email delivery now works via the Vercel backend.
- Tech Stack
- Project Structure
- Local Setup
- Database Schema
- Google OAuth Configuration
- Environment Variables
- API Reference
- Frontend Routes
- Running Tests
- Architecture & Decisions
- Trade-offs & Known Limitations
- Challenges Faced
- What I Would Improve Given More Time
| Concern | Technology |
|---|---|
| Runtime | Node.js v18+ |
| API Server | Express.js 4 |
| Database | PostgreSQL via Supabase (pg) |
| Auth OAuth | Passport.js + passport-google-oauth20 |
| Auth Credentials | bcrypt (cost factor 12) |
| Tokens | JWT (jsonwebtoken) — stateless |
| Validation | Zod |
| Email / OTP | Nodemailer via Gmail SMTP |
| Async Errors | express-async-errors |
| Testing | Jest + Supertest |
| Concern | Technology |
|---|---|
| Framework | React 19 (Vite) |
| Routing | React Router v7 |
| HTTP Client | Axios (JWT interceptor) |
| Date Utilities | date-fns |
| Styling | Vanilla CSS / inline styles |
TaskBoard/
├── client/
│ ├── src/
│ │ ├── api/
│ │ │ └── axios.js # Axios instance + JWT interceptor
│ │ ├── components/
│ │ │ ├── Navbar.jsx
│ │ │ ├── ProtectedRoute.jsx
│ │ │ ├── TaskCard.jsx # Toggle, delete, overdue badge
│ │ │ ├── TaskForm.jsx # Title, description, priority, due date
│ │ │ └── PriorityBadge.jsx # Low / Medium / High badge
│ │ ├── context/
│ │ │ └── AuthContext.jsx # Global auth state + token persistence
│ │ ├── pages/
│ │ │ ├── LoginPage.jsx
│ │ │ ├── RegisterPage.jsx
│ │ │ ├── VerifyOtpPage.jsx # 6-digit OTP input + resend cooldown
│ │ │ ├── OAuthCallbackPage.jsx
│ │ │ └── DashboardPage.jsx
│ │ └── App.jsx
│ ├── vercel.json
│ └── vite.config.js
│
└── server/
├── app.js
├── server.js
├── src/
│ ├── config/
│ │ ├── db.js # PostgreSQL pool (Supabase + SSL)
│ │ └── passport.js # Google OAuth strategy
│ ├── controllers/
│ │ ├── authController.js # register, login, verifyOtp, resendOtp, me
│ │ └── taskController.js # getTasks, createTask, updateTask, deleteTask
│ ├── middleware/
│ │ ├── verifyToken.js
│ │ └── errorHandler.js # Centralised error response mapper
│ ├── routes/
│ │ ├── authRoutes.js
│ │ └── taskRoutes.js
│ ├── services/
│ │ ├── authService.js # User + OTP DB queries
│ │ ├── taskService.js # Task DB queries
│ │ └── emailService.js # Nodemailer Gmail SMTP transporter
│ ├── utils/
│ │ ├── jwt.js
│ │ └── otp.js # generateOtp, hashOtp, compareOtp (timing-safe)
│ └── validators/
│ ├── authValidator.js # Zod: register, login, verifyOtp, resendOtp
│ └── taskValidator.js # Zod: createTask, updateTask
├── tests/
│ ├── auth.test.js # 20 auth integration tests
│ └── tasks.test.js # 5 task API integration tests
│ └── vercel.json
└── package.json
---
- Node.js v18+
- A Supabase project (free tier is fine) or any PostgreSQL instance
git clone https://github.com/YOUR_USERNAME/TaskBoard.git
cd TaskBoardcd server
npm install
cp .env.example .env
# Fill in all values in server/.env (see Environment Variables below)
npm run dev
# Runs at http://localhost:5000cd client
npm install
cp .env.example .env
# Set VITE_API_URL=http://localhost:5000/api
npm run dev
# Runs at http://localhost:5173Run this SQL in your Supabase SQL Editor:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
name VARCHAR(255),
google_id VARCHAR(255) UNIQUE,
is_verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE otp_codes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
code_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT FALSE,
priority VARCHAR(50) DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
due_date TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);- Go to Google Cloud Console → APIs & Services → Credentials.
- Create an OAuth 2.0 Client ID (Web application).
- Add to Authorized JavaScript origins:
http://localhost:5173 - Add to Authorized redirect URIs:
http://localhost:5000/api/auth/google/callback - Copy Client ID and Client Secret into
server/.env.
PORT=5000
NODE_ENV=development
DATABASE_URL=postgresql://postgres:[PASSWORD]@db.[HOST].supabase.co:5432/postgres
JWT_SECRET=generate_with_node_crypto_randomBytes_64
JWT_EXPIRES_IN=7d
GOOGLE_CLIENT_ID=your_google_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_google_client_secret
GOOGLE_CALLBACK_URL=http://localhost:5000/api/auth/google/callback
FRONTEND_URL=http://localhost:5173
CORS_ORIGIN=http://localhost:5173
# Gmail SMTP — use a Gmail App Password, not your regular password
GMAIL_USER=your.gmail@gmail.com
GMAIL_APP_PASSWORD=16_char_app_password
EMAIL_FROM=TaskBoard <your.gmail@gmail.com>VITE_API_URL=http://localhost:5000/api| Method | Endpoint | Body | Response |
|---|---|---|---|
POST |
/auth/register |
{ email, password, name } |
201 { message, email } |
POST |
/auth/login |
{ email, password } |
200 { token, user } |
POST |
/auth/verify-otp |
{ email, otp } |
200 { token, user } |
POST |
/auth/resend-otp |
{ email } |
200 { message } |
POST |
/auth/logout |
— | 200 { message } |
GET |
/auth/me |
— (Bearer JWT) | 200 user object |
GET |
/auth/google |
— | Redirect to Google |
GET |
/auth/google/callback |
— | Redirect to /oauth-callback?token=... |
| Method | Endpoint | Body | Response |
|---|---|---|---|
GET |
/tasks |
— | 200 [...tasks] |
POST |
/tasks |
{ title, description?, priority?, due_date? } |
201 task |
PATCH |
/tasks/:id |
any task fields | 200 updated task |
DELETE |
/tasks/:id |
— | 204 |
| Path | Component | Guard |
|---|---|---|
/login |
LoginPage |
Public |
/register |
RegisterPage |
Public |
/verify-otp |
VerifyOtpPage |
Public |
/oauth-callback |
OAuthCallbackPage |
Public |
/dashboard |
DashboardPage |
🔒 Protected |
cd server
npm run test| Suite | Tests | Coverage |
|---|---|---|
auth.test.js |
20 | Register, login, OTP verify/resend, edge cases |
tasks.test.js |
5 | Auth protection, ownership, CRUD |
All services are mocked, no real DB or email server needed.
Routes → Controllers → Services → DB. Controllers handle only HTTP (parse, validate, respond). All DB logic lives in authService and taskService. This made the test suite clean, mocking the service layer with jest.mock() covers every HTTP branch without touching the database.
JWTs are issued on login/OTP-verify/OAuth-callback and stored in localStorage. No session table, no Redis. A single Axios interceptor attaches the token to every request automatically. Simple, but see trade-offs below.
Registration creates an is_verified = FALSE user. A 6-digit OTP is generated via crypto.randomInt (CSPRNG), SHA-256 hashed, and stored, the plaintext is never persisted. crypto.timingSafeEqual is used for comparison. If the email send fails, the user record is rolled back to prevent orphaned accounts.
Google pre-verifies email ownership, so OAuth users are immediately set to is_verified = TRUE. The same Passport strategy handles both new Google users and existing accounts transparently.
Every POST/PATCH body is parsed through a Zod schema before any DB call. Invalid requests return 400 with the first human-readable issue message.
Every mutating task endpoint fetches the task first and checks task.user_id === req.user.id. GET /api/tasks is scoped to WHERE user_id = $1. Users can never read or modify each other's data.
localStorage is simpler and works cleanly across the split Vercel/Render deployment without CORS cookie headaches. The trade-off is XSS exposure, a malicious script could read the token. In production, HttpOnly SameSite=Strict cookies are the right call.
7-day JWT with no rotation. On expiry, users must log in again. A production system would use short-lived access tokens (15 min) + rotating refresh tokens in an HttpOnly cookie.
OTPs are short-lived (10 min) and single-use, so bcrypt's slow hash is unnecessary overhead. SHA-256 is fine here but the security comes from expiry and one-time use, not slow hashing alone.
Render's free tier blocks outbound SMTP ports. The backend was moved to Vercel where SMTP works. If redeployed to Render free then the OTP emails will fail and registration rolls back to prevent orphaned accounts. The long-term fix is switching to an HTTP-based mail provider (Resend, SendGrid) on port 443.
Auth endpoints (login, register, OTP) have no brute-force protection beyond the 60-second OTP resend cooldown. express-rate-limit would cover this properly.
JWT logout is client-side only, the token is deleted from localStorage but remains technically valid server-side until expiry. A token blacklist or short expiry with refresh tokens would fix this.
- Zod v4 migration = controllers were crashing silently because Zod changed
.error.error[0]to.error.issues[0]. Spent time suspecting.envissues before finding the one-line fix. - React Router + Vercel 404 = SPA routes returned 404 on hard refresh. Fixed by adding rewrite rules in
vercel.json. - CORS case-sensitivity = the
CORS_ORIGINenv var had a trailing slash mismatch with the frontend URL. Took a while to spot. - Google OAuth redirect URI = mismatches between Google Console,
.env, and deployment URL caused OAuth failures across environments. - Render SMTP block = switched from Resend (domain restriction on free tier) to Nodemailer + Gmail SMTP. Worked locally. Discovered Render free blocks SMTP only after deployment. Moved backend to Vercel.
- OTP email provider confusion = Resend requires a custom domain for arbitrary recipients on free tier,such that only account holders can get otp. Nodemailer with Gmail App Passwords was the working alternative.
- HttpOnly cookie JWT = eliminates the XSS vector from
localStorage. - Refresh token rotation = short-lived access tokens + rotating refresh tokens.
- HTTP email API = replace Gmail SMTP with Resend or Mailgun HTTP API to avoid SMTP port restrictions on any host, with a domain availability.
- Rate limiting =
express-rate-limiton all auth endpoints. - Forgot password flow = separate reset-password route with its own OTP or signed token,constraints for setting a strong password.
- Task history / activity log = track created, updated, and completed timestamps.
- Calendar view = group tasks by due date with a proper calendar UI.
- Pagination & filtering = server-side
WHEREclauses for status, priority, and date ranges.
| Feature | Status |
|---|---|
| PostgreSQL persistence (Supabase) | ✅ |
| Due dates with overdue indicators | ✅ |
| Task priority levels (Low / Medium / High) | ✅ |
| 25 integration tests (Jest + Supertest) | ✅ |
| Deployed frontend (Vercel) | ✅ |
| Deployed backend (Vercel/Render) | ✅ |
Built for the Gatherly Engineering take-home exercise.