Votebox Development Skill
This skill provides context and best practices for developing Votebox, a cloud-native SaaS platform for democratic music voting in venues.
SKILL.md
Full skill instructions
Votebox Development Skill
This skill provides context and best practices for developing Votebox, a cloud-native SaaS platform for democratic music voting in venues.
Project Overview
Votebox enables pub and club guests to vote on music from their phones. Venues create themed events (e.g., "Doom Rock Night", "Black Metal Evening") and guests vote on tracks from curated playlists or genres.
Tech Stack: Next.js 14, NestJS, TypeScript, PostgreSQL, Redis, Socket.io, Spotify API
Developer: Experienced Cloud Architect & DevOps Leader with expertise in Terraform, Kubernetes, and enterprise CI/CD pipelines.
Project Structure
votebox/
├── apps/
│ ├── web/ # Next.js PWA (Guest voting interface + Display screen)
│ ├── admin/ # Next.js Admin Dashboard for venue management
│ └── api/ # NestJS Backend API with WebSocket support
├── packages/
│ ├── ui/ # Shared React components with Tailwind CSS
│ ├── database/ # Prisma schema, migrations, and seed data
│ ├── types/ # Shared TypeScript types and interfaces
│ ├── config/ # Shared ESLint, Prettier, TypeScript configs
│ └── utils/ # Shared utility functions
├── docker/ # Dockerfiles for containerization
│ ├── Dockerfile.api
│ ├── Dockerfile.web
│ └── docker-compose.yml
├── .github/
│ └── workflows/ # CI/CD with GitHub Actions
│ ├── ci.yml # Test and build pipeline
│ └── deploy.yml # Deployment pipeline
└── terraform/ # Infrastructure as Code (optional for cloud deployment)
Core Concepts
Event-Based Architecture
- Venues create Events (themed music sessions)
- Events have playlist configurations (genre, Spotify playlist, or custom)
- Guests vote on tracks without requiring accounts
- Queue is dynamically reordered based on votes
Real-time Updates
- WebSocket connections for live queue updates
- Vote updates broadcast to all connected clients
- Now playing status synced across all devices
Multi-Tenancy
- Each venue is a separate tenant
- Data isolation via
venueIdin all queries - Subscription-based access control
Development Guidelines
TypeScript Conventions
// ✅ Use explicit types for function parameters and return values
async function createVote(eventId: string, trackId: string, sessionId: string): Promise<Vote> {
// Implementation
}
// ✅ Use interfaces for complex objects
interface QueueItem {
position: number;
trackId: string;
trackName: string;
voteCount: number;
score: number;
}
// ✅ Use type guards for runtime checks
function isVoteRateLimitError(error: unknown): error is VoteRateLimitError {
return error instanceof VoteRateLimitError;
}
// ❌ Avoid 'any' - use 'unknown' and narrow with type guards
NestJS Patterns
// ✅ Use proper dependency injection
@Injectable()
export class EventService {
constructor(
private readonly prisma: PrismaService,
private readonly spotify: SpotifyService,
private readonly cache: CacheService
) {}
}
// ✅ Use DTOs with validation decorators
export class CreateEventDto {
@IsString()
@MinLength(3)
@MaxLength(100)
name: string;
@IsDate()
@Type(() => Date)
startTime: Date;
@ValidateNested()
@Type(() => PlaylistConfigDto)
playlistConfig: PlaylistConfigDto;
}
// ✅ Use guards for authorization
@UseGuards(JwtAuthGuard, VenueOwnerGuard)
@Post(':venueId/events')
async createEvent(
@Param('venueId') venueId: string,
@Body() dto: CreateEventDto
) {
return this.eventService.create(venueId, dto);
}
// ✅ Use interceptors for cross-cutting concerns
@UseInterceptors(CacheInterceptor)
@Get(':id')
async findOne(@Param('id') id: string) {
return this.eventService.findOne(id);
}
Prisma Best Practices
// ✅ Use transactions for related operations
await prisma.$transaction(async (tx) => {
const vote = await tx.vote.create({
data: { eventId, trackId, sessionId }
});
await tx.queueItem.upsert({
where: { eventId_trackId: { eventId, trackId } },
update: { voteCount: { increment: 1 } },
create: { eventId, trackId, voteCount: 1 }
});
return vote;
});
// ✅ Use select to reduce data transfer
const events = await prisma.event.findMany({
where: { venueId, status: 'ACTIVE' },
select: {
id: true,
name: true,
startTime: true,
venue: { select: { name: true, slug: true } }
}
});
// ✅ Use proper indexes (defined in schema.prisma)
@@index([venueId, status])
@@index([eventId, votedAt])
@@unique([eventId, trackId])
Next.js App Router Patterns
// ✅ Use Server Components by default for data fetching
// app/events/[id]/page.tsx
export default async function EventPage({ params }: { params: { id: string } }) {
const event = await getEvent(params.id);
return <EventDetails event={event} />;
}
// ✅ Use Client Components for interactivity
// components/VoteButton.tsx
'use client';
export function VoteButton({ trackId }: { trackId: string }) {
const [isVoting, setIsVoting] = useState(false);
const handleVote = async () => {
setIsVoting(true);
await submitVote(trackId);
setIsVoting(false);
};
return (
<button onClick={handleVote} disabled={isVoting}>
{isVoting ? 'Voting...' : 'Vote'}
</button>
);
}
// ✅ Use Server Actions for mutations
// app/actions.ts
'use server';
export async function submitVote(trackId: string) {
const session = await getServerSession();
// Validate, call API, revalidate
revalidatePath('/events/[id]');
}
// ✅ Use middleware for auth and redirects
// middleware.ts
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token');
if (!token && request.nextUrl.pathname.startsWith('/admin')) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
WebSocket Patterns
// ✅ Use rooms for scoped broadcasting
@WebSocketGateway({ cors: true })
export class EventsGateway {
@SubscribeMessage('joinEvent')
handleJoinEvent(@MessageBody() data: { eventId: string }, @ConnectedSocket() client: Socket) {
client.join(`event:${data.eventId}`);
client.emit('joined', { eventId: data.eventId });
}
// ✅ Broadcast to specific room
broadcastVoteUpdate(eventId: string, update: VoteUpdate) {
this.server.to(`event:${eventId}`).emit('voteUpdate', update);
}
// ✅ Handle disconnections
@SubscribeMessage('disconnect')
handleDisconnect(@ConnectedSocket() client: Socket) {
// Cleanup
}
}
Queue Algorithm
// ✅ Calculate queue score with multiple factors
function calculateQueueScore(item: QueueItem): number {
const baseScore = item.voteCount;
// Recency bonus (votes in last 5 minutes)
const timeSinceLastVote = Date.now() - item.lastVotedAt.getTime();
const recencyBonus = timeSinceLastVote < 300000 ? 2 : 0;
// Diversity bonus (different artist than current track)
const diversityBonus = item.artistName !== currentTrack.artistName ? 1 : 0;
return baseScore + recencyBonus + diversityBonus;
}
// ✅ Reorder queue based on scores
async function reorderQueue(eventId: string) {
const items = await prisma.queueItem.findMany({
where: { eventId, isPlayed: false },
orderBy: { score: 'desc' },
});
// Update positions in transaction
await prisma.$transaction(
items.map((item, index) =>
prisma.queueItem.update({
where: { id: item.id },
data: { position: index + 1 },
})
)
);
}
Spotify Integration
// ✅ Handle OAuth flow
async authenticateVenue(code: string, venueId: string) {
const tokens = await this.spotifyAuth.getTokens(code);
await this.prisma.venue.update({
where: { id: venueId },
data: {
spotifyAccessToken: tokens.access_token,
spotifyRefreshToken: tokens.refresh_token,
spotifyTokenExpiry: new Date(Date.now() + tokens.expires_in * 1000)
}
});
}
// ✅ Refresh tokens when expired
async getValidAccessToken(venueId: string): Promise<string> {
const venue = await this.prisma.venue.findUnique({
where: { id: venueId },
select: {
spotifyAccessToken: true,
spotifyRefreshToken: true,
spotifyTokenExpiry: true
}
});
if (venue.spotifyTokenExpiry > new Date()) {
return venue.spotifyAccessToken;
}
// Refresh token
const newTokens = await this.spotifyAuth.refreshToken(
venue.spotifyRefreshToken
);
await this.prisma.venue.update({
where: { id: venueId },
data: {
spotifyAccessToken: newTokens.access_token,
spotifyTokenExpiry: new Date(Date.now() + newTokens.expires_in * 1000)
}
});
return newTokens.access_token;
}
// ✅ Implement rate limiting and retry logic
async searchTracks(query: string, limit: number) {
return retry(
async () => {
const response = await this.spotify.search({
q: query,
type: 'track',
limit
});
return response.tracks.items;
},
{
retries: 3,
backoff: 'exponential',
onRetry: (error, attempt) => {
this.logger.warn(`Spotify API retry ${attempt}:`, error);
}
}
);
}
Caching Strategies
// ✅ Cache track lists for events
async getEventTracks(eventId: string): Promise<Track[]> {
const cacheKey = `tracks:event:${eventId}`;
// Try cache first
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Load from Spotify
const tracks = await this.loadTracksFromSpotify(eventId);
// Cache for 1 hour
await this.redis.setex(cacheKey, 3600, JSON.stringify(tracks));
return tracks;
}
// ✅ Cache Spotify API responses
async getRecommendations(seedGenres: string[]): Promise<Track[]> {
const cacheKey = `spotify:recommendations:${seedGenres.join(',')}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const recommendations = await this.spotify.getRecommendations({
seed_genres: seedGenres,
limit: 100
});
// Cache for 24 hours
await this.redis.setex(cacheKey, 86400, JSON.stringify(recommendations));
return recommendations;
}
Rate Limiting
// ✅ Implement vote rate limiting
async checkVoteRateLimit(sessionId: string): Promise<void> {
const key = `votes:ratelimit:${sessionId}`;
const count = await this.redis.incr(key);
if (count === 1) {
// First vote in window, set expiry (1 hour)
await this.redis.expire(key, 3600);
}
if (count > 3) {
const ttl = await this.redis.ttl(key);
throw new VoteRateLimitError(`Try again in ${ttl} seconds`);
}
}
// ✅ Implement global API rate limiting
@UseGuards(ThrottlerGuard)
@Throttle(100, 60) // 100 requests per minute
@Get()
async findAll() {
// Implementation
}
Error Handling
// ✅ Create custom error classes
export class VoteRateLimitError extends Error {
constructor(
message: string,
public retryAfter: number
) {
super(message);
this.name = 'VoteRateLimitError';
}
}
// ✅ Use exception filters
@Catch(VoteRateLimitError)
export class VoteRateLimitFilter implements ExceptionFilter {
catch(exception: VoteRateLimitError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
response.status(429).json({
error: 'RATE_LIMIT_EXCEEDED',
message: exception.message,
retryAfter: exception.retryAfter,
});
}
}
Testing
// ✅ Unit test with proper mocking
describe('VoteService', () => {
let service: VoteService;
let prisma: PrismaService;
let redis: RedisService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
VoteService,
{ provide: PrismaService, useValue: mockPrismaService },
{ provide: RedisService, useValue: mockRedisService },
],
}).compile();
service = module.get(VoteService);
prisma = module.get(PrismaService);
redis = module.get(RedisService);
});
it('should create a vote', async () => {
const vote = await service.create({
eventId: 'event1',
trackId: 'track1',
sessionId: 'session1',
});
expect(vote).toBeDefined();
expect(prisma.vote.create).toHaveBeenCalled();
});
it('should throw rate limit error', async () => {
redis.incr = jest.fn().resolvedValue(4); // Exceed limit
await expect(
service.create({
eventId: 'event1',
trackId: 'track1',
sessionId: 'session1',
})
).rejects.toThrow(VoteRateLimitError);
});
});
Common Tasks
Adding a New API Endpoint
- Create DTO in
apps/api/src/[module]/dto/ - Add method to service in
apps/api/src/[module]/[module].service.ts - Add controller method in
apps/api/src/[module]/[module].controller.ts - Add tests in
apps/api/src/[module]/[module].service.spec.ts - Update API documentation in
API_DESIGN.md
Adding a New UI Component
- Create component in
packages/ui/src/components/ - Export from
packages/ui/src/index.ts - Add Storybook story if needed
- Use in Next.js apps
Database Schema Changes
- Update
packages/database/prisma/schema.prisma - Run
npx prisma migrate dev --name [migration-name] - Update TypeScript types if needed
- Update seed data if needed
- Update
DATABASE_SCHEMA.mddocumentation
Adding Real-time Events
- Add event handler in
apps/api/src/websocket/events.gateway.ts - Add client listener in Next.js components
- Test with multiple clients
- Update
API_DESIGN.mdwith new events
Performance Considerations
- Database: Use indexes, limit queries, select only needed fields
- Cache: Cache Spotify responses, track lists, queue state
- WebSocket: Throttle broadcasts, use rooms, disconnect inactive clients
- API: Implement rate limiting, pagination, query optimization
Security Checklist
- ✅ Validate all input with DTOs
- ✅ Sanitize user input
- ✅ Use parameterized queries (Prisma handles this)
- ✅ Implement rate limiting
- ✅ Use HTTPS in production
- ✅ Store secrets in environment variables
- ✅ Implement CORS properly
- ✅ Use secure session cookies
- ✅ Validate JWT tokens
- ✅ Implement audit logging
Key Files Reference
CLAUDE.md- Full project context and guidelinesPROJECT_PLAN.md- Development timeline and phasesTECH_STACK.md- Technology choices and rationaleDATABASE_SCHEMA.md- Complete database designAPI_DESIGN.md- API specifications
Developer Notes
- Experience Level: Senior developer with extensive DevOps background
- Strengths: Infrastructure, Kubernetes, Terraform, CI/CD
- Preferences: Type safety, clean architecture, comprehensive testing
- Tools: NixOS, COSMIC Desktop, Obsidian for documentation
This skill should help you maintain consistency and best practices throughout the Votebox project development.
