Groups App

A server-rendered group trip planning platform built with Express 5 and EJS. Travelers create groups, propose journey legs, vote on destinations, and coordinate every detail. Features AI-generated cover images via Replicate FLUX and real-time polling updates.

Overview

The Groups App (internally called TRIPS) is a full-featured group travel coordination platform. It lets Travel Tamers clients form groups around trip ideas, propose and vote on journey legs (flight segments, hotel stays, excursions), post threaded comments with image attachments, and ultimately export finalized itineraries to Slack channels.

Unlike the marketing site (static) or the API (JSON), this is a traditional server-rendered MPA using Express 5 with EJS templates and Bootstrap 5.3 for styling. The Gold (#E8B83D) and Navy (#0F2440) design system carries through with full dark mode support via a 3-way theme toggle (light/dark/system) using [data-theme="dark"] CSS custom properties.

Groups app landing page showing Plan Your Next Adventure with featured trip cards
Landing page — "Plan Your Next Adventure" with discover, vote, and export features

Source of Truth: The Groups App owns all social data — posts, comments, votes, and images. The main TT-API handles contacts, deals, and bookings. Nexus consumes Groups data via the Service API for its social feed aggregation.

Tech Stack

Layer Technology Role
Runtime Node.js 20+ Server runtime (non-root Docker user)
Framework Express 5.2 HTTP server, routing, middleware
Templating EJS 4 + express-ejs-layouts Server-side HTML rendering with layout inheritance
Frontend CSS Bootstrap 5.3 + Tailwind 3.4 UI framework and utility classes
Database PostgreSQL 16 (pg driver) 13-table relational schema (groups_db)
Sessions express-session + connect-pg-simple Session storage in PostgreSQL
Auth bcryptjs 3 Password hashing and verification
Validation express-validator 7 Input validation on forms and API
Image Processing sharp 0.34 + multer 2 Upload handling, resize to WebP
AI Images Replicate FLUX API AI-generated trip cover images
Security helmet 8 + express-rate-limit 8 CSP headers, rate limiting, CSRF tokens
Error Tracking Sentry (optional) Production error monitoring
Testing Jest 30 + Supertest 7 130 integration and unit tests

Database Schema

The Groups App uses its own PostgreSQL database (groups_db) with 13 tables. All primary keys are UUIDs generated by gen_random_uuid(). Timestamps use TIMESTAMPTZ with auto-updating updated_at triggers. Migrations are raw SQL files tracked in a schema_migrations table.

Core Tables

Table Columns Purpose
users id, email, password_hash, display_name, bio, avatar_url, slack_user_id Registered users with optional Slack linking
groups id, name, description, destination, cover_image_url, visibility, status, min_members, max_members, owner_id, voting_mode, voting_threshold, departure_date, return_date, invite_token, slack_channel_id, exported_at Trip groups with lifecycle status and voting config
group_members id, group_id, user_id, role, joined_at Membership with roles: owner, admin, member
legs id, group_id, title, description, destination, start_date, end_date, estimated_cost, status, proposed_by, sort_order Journey segments proposed by members
leg_votes id, leg_id, user_id, vote (yes/no/abstain) Member votes on proposed legs
comments id, group_id, user_id, parent_id, content, image_url Threaded discussion with image attachments
notifications id, user_id, type, title, message, link, read_at In-app notifications for joins, votes, comments

Supporting Tables

Table Purpose
join_questions Custom questions owners can require new members to answer
join_requests Pending membership requests for private/gated groups
join_answers Answers to join questions, linked to requests
invitations Email invitations for users not yet registered
schema_migrations Migration tracking (filename + ran_at)
session PostgreSQL-backed session store (connect-pg-simple)

Group Lifecycle

forming

New group created. Members can join, but quorum not yet met.

viable

Member count reaches min_members threshold. Auto-transitions when enough join.

planning

Owner advances status. Legs are finalized via voting, dates are set.

departed

Trip is underway. Can export to Slack channel for real-time coordination.

completed

Trip finished. Group becomes read-only archive.

Voting Modes: Groups support unanimous (all members must vote yes) or threshold (configurable percentage, default 66%). When all members have voted on a leg, the system auto-resolves it to approved or rejected.

Migrations

File Creates / Alters
001_create_users.sqlusers table + email index + updated_at trigger
002_create_groups.sqlgroups table with status, visibility, voting config
003_create_group_members.sqlgroup_members with unique(group_id, user_id)
004_create_join_questions.sqlCustom join questions per group
005_create_join_requests.sqlMembership request queue
006_create_join_answers.sqlAnswers to join questions
007_create_legs.sqlJourney legs with estimated_cost, sort_order
008_create_leg_votes.sqlVotes on legs (yes/no/abstain)
009_create_comments.sqlThreaded comments with image support
010_create_notifications.sqlIn-app notification system
011_add_slack_fields.sqlslack_channel_id, slack_channel_url, exported_at on groups
012_add_notification_types.sqlAdditional notification type column
013_add_invite_token.sqlinvite_token on groups for shareable links
014_add_user_slack_id.sqlslack_user_id on users for Slack linking
015_create_invitations.sqlEmail invitation tracking table
016_seed_fake_trips.sqlSample data for demonstration
017_add_password_reset.sqlPassword reset token + expiry fields

Routes & Endpoints

The app has 7 route files mounted in server.js. Page routes serve EJS templates; API routes return JSON. All state-changing routes require CSRF tokens (except /api/*).

Auth Routes /auth

MethodPathDescription
GET/auth/loginLogin form
POST/auth/loginAuthenticate user, regenerate session
GET/auth/signupRegistration form
POST/auth/signupCreate account, auto-accept pending invites
GET/auth/forgot-passwordPassword reset request form
POST/auth/forgot-passwordSend reset email (rate-limited: 5/15min)
GET/auth/reset-passwordReset form with token validation
POST/auth/reset-passwordSet new password from reset token
POST/auth/logoutDestroy session and clear cookie

Feed Routes /feed

MethodPathDescription
GET/feedBrowse public groups (search, filter, sort, paginate). Supports ?format=json.
GET/feed/my-groupsDashboard of user's groups (owned, admin, member)

Group Routes /groups

Group Management

MethodPathAuthDescription
GET/groups/newUserCreate group form
POST/groupsUserCreate new group
GET/groups/:idPublicGroup detail with legs, comments, viability
GET/groups/:id/editOwner/AdminEdit group form
PUT/groups/:idOwner/AdminUpdate group details
DELETE/groups/:idOwnerDelete group
GET/groups/:id/settingsOwner/AdminUnified settings page
POST/groups/:id/statusOwnerAdvance lifecycle (viable → planning → departed → completed)
GET/groups/:id/updatesUserPolling endpoint for real-time updates

Cover Images

MethodPathDescription
POST/groups/:id/generate-coverAI-generate cover via Replicate FLUX
POST/groups/:id/upload-coverUpload custom cover (resized to 1200px WebP)

Membership

MethodPathDescription
GET/groups/invite/:tokenJoin via shareable invite link
GET/groups/:id/joinJoin request form (private/gated groups)
POST/groups/:id/joinSubmit join request or direct-join if public
POST/groups/:id/leaveLeave group (owners must transfer first)
POST/groups/:id/generate-inviteGenerate invite link token
POST/groups/:id/invite-emailInvite by email (auto-adds or sends invitation)
POST/groups/:id/export-slackExport finalized trip to Slack channel
GET/groups/:id/requestsManage pending join requests
POST/groups/:id/requests/:requestId/approveApprove join request
POST/groups/:id/requests/:requestId/denyDeny join request
POST/groups/:id/members/:userId/removeRemove member
POST/groups/:id/members/:userId/roleChange member role
POST/groups/:id/transfer-ownershipTransfer group ownership

Legs (Journey Segments)

MethodPathDescription
GET/groups/:id/legs/newPropose leg form
POST/groups/:id/legsCreate proposed leg
POST/groups/:id/legs/:legId/start-votingOpen voting on a leg
POST/groups/:id/legs/:legId/voteCast vote (yes/no/abstain)
POST/groups/:id/legs/reorderReorder legs (drag-and-drop)
DELETE/groups/:id/legs/:legIdDelete leg (proposer or admin)

Comments

MethodPathDescription
POST/groups/:id/commentsPost comment (with optional image upload)
DELETE/groups/:id/comments/:commentIdDelete comment (author or admin)

User Routes /users

MethodPathDescription
GET/users/:idPublic profile (own profile sees private groups too)
GET/users/:id/editEdit profile form (own profile only)
PUT/users/:idUpdate display name, bio
POST/users/:id/avatarUpload avatar (resized 256x256 WebP)
POST/users/:id/link-slackLink Slack account by email lookup
DELETE/users/:idDelete account (transfers ownership, CASCADE deletes)

Notification Routes /notifications

MethodPathDescription
GET/notificationsPaginated notifications list (20 per page)
POST/notifications/:id/readMark one as read, redirect to link
POST/notifications/read-allMark all as read

JSON APIs

Session-Authenticated API

/api/*

Requires logged-in session. Used by client-side JavaScript.

  • GET /api/groups — list user's + public groups
  • GET /api/groups/:id — group detail with legs, members
  • GET /api/stats — aggregate stats

Service API (Nexus Integration)

/api/service/*

Authenticated via X-Service-Token header (timing-safe comparison).

  • GET /api/service/social-feed — aggregated activity feed
  • GET /api/service/social-stats — engagement metrics
  • GET /api/service/groups — all groups (no session)

Authentication & Security

The Groups App uses session-based authentication with PostgreSQL-backed session storage. No JWTs — this is a traditional server-rendered app where sessions are the correct pattern.

Session Management

  • Store: connect-pg-simple persists sessions in PostgreSQL (survives server restarts)
  • Cookie: connect.sid with httpOnly, secure (in production), sameSite: lax
  • Secret: SESSION_SECRET env var (required, server exits without it)
  • Session fixation: Sessions are regenerated on login and signup

Password Security

  • Passwords hashed with bcryptjs (cost factor 10)
  • Password reset tokens are cryptographically random, expire in 1 hour
  • Reset endpoint rate-limited to 5 requests per 15 minutes per IP
  • Reset response is always the same message (prevents email enumeration)

CSRF Protection

Every session gets a 32-byte random CSRF token stored in req.session.csrfToken. All POST/PUT/PATCH/DELETE requests must include the token as _csrf form field or X-CSRF-Token header. API routes (/api/*) are exempt since they use other auth mechanisms.

<!-- In every EJS form -->
<input type="hidden" name="_csrf" value="<%= csrfToken %>">

Rate Limiting

ScopeWindowMax Requests
Auth routes (/auth/*)15 minutes20
Password reset15 minutes5
General (all routes)15 minutes200

Role-Based Access

Group membership has three roles with escalating permissions:

RolePermissions
ownerAll permissions: delete group, advance status, manage questions, transfer ownership, export to Slack, change member roles
adminEdit group, manage settings, approve/deny join requests, remove members, start voting, manage covers
memberView group, propose legs, vote, post comments, leave group

UUID Validation: All :id route parameters are validated against a strict UUID regex pattern. Invalid UUIDs return 404 immediately, preventing injection attacks and database errors.

Views & Templates

The app uses EJS templates with express-ejs-layouts for layout inheritance. All templates live under trips/views/ with a clear hierarchy.

Template Structure

views/
  layouts/
    main.ejs            # Base HTML layout (head, navbar, flash, footer)
  pages/
    home.ejs            # Landing page with featured groups
    feed.ejs            # Browse groups with search/filter
    my-groups.ejs       # User's groups dashboard
    group-detail.ejs    # Full group view (legs, comments, viability)
    group-form.ejs      # Create/edit group form
    group-settings.ejs  # Unified settings (members, questions, invites)
    leg-form.ejs        # Propose a journey leg
    join-request.ejs    # Join request form with custom questions
    manage-requests.ejs # Approve/deny join requests
    manage-questions.ejs# Configure join questions
    profile.ejs         # User profile page
    profile-edit.ejs    # Edit profile form
    notifications.ejs   # Notification center
    auth/
      login.ejs         # Login form
      signup.ejs        # Registration form
      forgot-password.ejs
      reset-password.ejs
    errors/
      404.ejs           # Not found page
      500.ejs           # Server error page
  partials/
    navbar.ejs          # Top navigation bar
    footer.ejs          # Page footer
    flash.ejs           # Flash message display (success, error, info)
    group-card.ejs      # Group card component (used in feed)
    leg-card.ejs        # Leg card with vote counts
    comment-thread.ejs  # Threaded comment component
    viability-meter.ejs # Visual progress toward min_members

Template Helpers

Every template has access to these globally-injected helper functions:

HelperPurpose
formatDate(date)Format dates for display
timeAgo(date)Relative time ("3 hours ago")
truncate(str, len)Truncate text with ellipsis
sanitizeHtml(str)Strip HTML tags (XSS prevention)
defaultAvatar(name)Generate UI Avatars URL from display name
currentUserCurrent logged-in user object (or null)
csrfTokenCSRF token for forms
unreadNotificationCountBadge count for navbar

Dark Mode & Accessibility (R11)

The Groups App now features a comprehensive dark mode implementation with a 3-way theme toggle in the navbar: light, dark, and system (follows OS preference). This replaced the earlier broken dark: Tailwind class approach.

Theme Mechanism

AspectImplementation
Attribute [data-theme="dark"] on the <html> element (NOT html.dark class)
Toggle 3-way toggle in navbar: light / dark / system. Stored in localStorage.
CSS approach CSS custom properties (variables) scoped under [data-theme="dark"] selector, replacing broken Tailwind dark: utility classes
Templates updated 10 EJS templates converted from Tailwind dark: classes to CSS custom properties
Flash alerts Flash message styling now uses CSS custom properties, fixing invisible alerts in dark mode

Gold Color Correction

The gold accent was corrected from #C9A84C to #E8B83D across 4 files for better contrast and visual consistency:

  • routes/auth.js
  • routes/groups.js
  • utils/notify.js
  • favicon.svg and hero-bg.svg

Accessibility Improvements

AreaChange
Modals Added role="dialog", aria-modal="true", and Escape key close handler
Comment images Added descriptive alt text on comment image attachments
Login/Signup forms Added autocomplete attributes (email, current-password, new-password, name)
Dark mode alerts Fixed flash alerts that were invisible in dark mode (white text on white background)

AI Image Generation

Group owners and admins can generate cover images using the Replicate FLUX API. The system builds a prompt from the group's name, destination, and description, then calls the FLUX model to create a travel-themed cover image.

Prompt Construction

buildCoverPrompt() combines group name, destination, and description into a structured prompt optimized for travel imagery.

utils/flux.js

Replicate API Call

The prompt is sent to the FLUX model on Replicate. Users can also provide a custom prompt override.

REPLICATE_API_TOKEN

Image Downloaded & Saved

The generated image is downloaded and saved to public/uploads/group-covers/{groupId}.webp.

sharp + WebP

Database Updated

The group's cover_image_url is updated to the local path.

Fallback: If REPLICATE_API_TOKEN is not set, the generate-cover endpoint will return an error flash message. Groups use a default SVG cover (public/images/default-cover.svg) until a cover is generated or uploaded.

Users can also upload their own cover images, which are processed through sharp to resize (max 1200px wide) and convert to WebP at 85% quality.

SSH Tunnel (Local Development)

The Groups App's PostgreSQL database runs inside a Docker container on the VPS. For local development, you connect via an SSH tunnel that forwards a local port to the container's internal network address.

Architecture Detail

Local → VPS → Docker Network

# Open the SSH tunnel (runs in background)
ssh -f -N -L 5433:172.20.0.2:5432 vps

# Local port 5433 → VPS internal Docker network → PostgreSQL container
# DATABASE_URL=postgresql://user:pass@localhost:5433/groups_db

The tunnel maps local port 5433 to the PostgreSQL container at 172.20.0.2:5432 on the VPS Docker bridge network. This avoids exposing the database port publicly while allowing full local development.

Important: The SSH tunnel must be running before starting the dev server. If the tunnel drops, all database queries will fail. Use ssh -f -N to run it as a background process, or add it to your shell profile.

Development

Quick Start

# 1. Start SSH tunnel to VPS PostgreSQL
ssh -f -N -L 5433:172.20.0.2:5432 vps

# 2. Install dependencies
cd trips/
npm install

# 3. Run database migrations
npm run migrate

# 4. Seed sample data
npm run seed

# 5. Start dev server (nodemon with hot reload)
npm run dev
# App at http://localhost:3000

Available Commands

CommandDescription
npm run devStart with nodemon (auto-restart on file changes)
npm startProduction start (node server.js)
npm run migrateRun pending SQL migrations
npm run seedSeed database with sample data
npm testRun Jest test suite (130 tests)
npm run build:cssBuild Tailwind CSS (minified)
npm run dev:cssWatch mode for Tailwind CSS changes

Project Structure

trips/
  server.js            # Express entry point (237 lines)
  config/
    db.js              # PostgreSQL pool configuration
    session.js         # Session middleware config
  routes/              # 7 route files
    auth.js            # Login, signup, password reset
    feed.js            # Group browsing and search
    groups.js          # Group CRUD, membership, legs, comments (1120 lines)
    users.js           # Profile management, avatar upload
    notifications.js   # Notification center
    api.js             # Session-authenticated JSON API
    serviceApi.js      # Service token API for Nexus
  controllers/         # Business logic (separated from routes)
  models/              # 10 database models
    User.js            # User CRUD, password hashing, reset tokens
    Group.js           # Group CRUD, viability checks, invite tokens
    Member.js          # Membership, roles, ownership transfer
    Leg.js             # Journey legs, voting lifecycle
    LegVote.js         # Vote casting, auto-resolution
    Comment.js         # Threaded comments with images
    Notification.js    # In-app notification system
    JoinRequest.js     # Membership request queue
    JoinQuestion.js    # Custom join questions
    Invitation.js      # Email invitation tracking
  middleware/
    auth.js            # requireAuth, optionalAuth, requireGroupRole
    validate.js        # express-validator rules for forms
    upload.js          # multer config for covers, comments, avatars
    errorHandler.js    # 404 and 500 error handlers
    validateUUID.js    # UUID format validation
  utils/
    flash.js           # Flash message helpers
    helpers.js         # Date formatting, truncation, sanitization
    email.js           # Email sending utility
    flux.js            # Replicate FLUX API integration
    notify.js          # In-app notification dispatch
    slack.js           # Slack API (channel export, user lookup)
    groupsSync.js      # Sync events to TT-API (member joins/leaves)
  views/               # 27 EJS templates
  public/
    css/style.css      # Custom Gold/Navy design system + dark mode via [data-theme="dark"] CSS custom properties
    css/tailwind.css   # Generated Tailwind output
    js/app.js          # Client-side JavaScript
  migrations/          # 17 SQL migration files
  seeds/               # Database seed scripts
  tests/               # Jest test files

Environment Variables

VariableRequiredDescription
DATABASE_URLRequiredPostgreSQL connection string
SESSION_SECRETRequiredSecret for signing session cookies
APP_URLOptionalBase URL for email links (default: http://localhost:3000)
PORTOptionalServer port (default: 3000)
NODE_ENVOptionalproduction / development / test
REPLICATE_API_TOKENOptionalReplicate API key for AI cover images
SLACK_BOT_TOKENOptionalSlack Bot Token for channel export and user lookup
SERVICE_API_TOKENOptionalToken for Nexus service API authentication
SENTRY_DSNOptionalSentry error tracking DSN

Testing

The Groups App has a comprehensive Jest test suite with 130 tests covering authentication flows, group operations, membership, voting, comments, and API endpoints. Tests use Supertest for HTTP-level integration testing.

Running Tests

# Run the full test suite
npm test

# Run a specific test file
npm test -- tests/setup.test.js

# Jest flags used:
#   --runInBand       (sequential execution, avoids DB conflicts)
#   --detectOpenHandles (identify leaking connections)
#   --forceExit       (exit after tests complete)

CSRF Test Helper

Tests that perform state-changing operations need a valid CSRF token. The test suite includes a helper at tests/helpers/csrf.js that extracts the CSRF token from a GET request and includes it in subsequent POST requests.

// tests/helpers/csrf.js pattern
const agent = supertest.agent(app);
// 1. GET a page to establish session + get CSRF token
const res = await agent.get('/auth/login');
const csrfToken = extractCsrf(res.text);
// 2. Include token in POST
await agent.post('/auth/login')
  .send({ email, password, _csrf: csrfToken });

Never bypass CSRF in tests. The test suite uses the real CSRF middleware to ensure security is validated end-to-end. If a test fails on CSRF, fix the test helper, not the middleware.

Deployment

The Groups App runs as a Docker container on the VPS behind Traefik, serving groups.traveltamers.com. The Dockerfile uses Node 20 with a non-root user for security.

Deployment Pipeline

Code pushed or manual deploy

Changes pushed to main branch or manual SSH deployment to VPS.

Git / SSH

Files synced to VPS

Code deployed to /opt/groups-by-travel-tamers/ on the VPS.

tar + SSH

Docker rebuild

Docker image rebuilt with Node 20, non-root user. Dependencies installed in container.

Docker

Migrations run

Pending SQL migrations applied to groups_db via the migration runner.

PostgreSQL

Health check verified

GET /health returns {"status":"ok","app":"groups"}. Health endpoint is before session middleware, so it requires no auth.

HTTP check

Production Infrastructure

# Docker container: groups
# Serves: groups.traveltamers.com
# Port: 3000 (internal)
# VPS path: /opt/groups-by-travel-tamers/
# Database: groups_db (PostgreSQL 16 in Docker)
# Traefik handles:
#   - SSL termination (Let's Encrypt)
#   - HTTP → HTTPS redirect
#   - Host-based routing

Health Endpoint

The /health route is mounted before session and CSRF middleware, so it can be called by monitoring tools without authentication:

// Response: 200 OK
{
  "status": "ok",
  "app": "groups",
  "timestamp": 1710000000000
}
Groups app scrolled down showing Featured Trips section with safari cruise and northern lights cards
Featured Trips — East African Safari, Mediterranean Cruise, Northern Lights Expedition
Groups app login form with email and password fields and gold Log In button
Login form — session-based auth with bcryptjs password hashing

Monitoring: The VPS health check script runs every 5 minutes via cron and alerts the #monitoring Slack channel if the Groups App health endpoint fails. Manual rollback is available by SSH-ing to the VPS and restarting with the previous Docker image.