Skip to content
Merged
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
39 changes: 24 additions & 15 deletions apps/api/migrations/0001_initial.sql
Original file line number Diff line number Diff line change
@@ -1,31 +1,40 @@
-- 1. Create Users Table
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
name TEXT,
email TEXT,
is_completed INTEGER DEFAULT 0,
completed INTEGER DEFAULT 0,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
);

-- 2. Create Stalls Table
CREATE TABLE stalls (
id INTEGER PRIMARY KEY,
name TEXT,
qr_slug TEXT UNIQUE,
-- 2. Create Items Table
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
logo TEXT,
qr_slug TEXT UNIQUE NOT NULL,
total_voters INTEGER DEFAULT 0,
qualified_voters INTEGER DEFAULT 0,
qualified_rating_sum INTEGER DEFAULT 0,
non_qualified_rating_sum INTEGER DEFAULT 0,
qualified_avg_rating REAL DEFAULT 0,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
);

-- 3. Create Ratings Table
CREATE TABLE ratings (
id INTEGER PRIMARY KEY,
user_id TEXT,
stall_id INTEGER,
rating INTEGER,
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
item_id INTEGER NOT NULL,
rating INTEGER NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
UNIQUE(user_id, stall_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (stall_id) REFERENCES stalls(id) ON DELETE CASCADE
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
);

-- 4.Adding an index for email lookups
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- 4. One rating per user per item
CREATE UNIQUE INDEX unique_vote ON ratings(user_id, item_id);

-- 5. Index for email lookups
CREATE UNIQUE INDEX idx_users_email ON users(email);
22 changes: 22 additions & 0 deletions apps/api/src/controllers/items.Controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Context } from 'hono'
import type { AppEnv } from '../types'
import { fetchItem } from '../services/items.Service'

export const getItemBySlug = async (c: Context<AppEnv>) => {
const slug = c.req.param('slug')

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

try {
const item = await fetchItem(c.env.DB, slug)
if (!item) {
return c.json({ success: false, message: 'Item not found' }, 404)
}
return c.json({ success: true, data: item })
} catch (e: any) {
console.error('Item lookup error:', e)
return c.json({ success: false, message: 'Internal Server Error' }, 500)
}
}
22 changes: 0 additions & 22 deletions apps/api/src/controllers/stalls.Controller.ts

This file was deleted.

6 changes: 3 additions & 3 deletions apps/api/src/controllers/vote.Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ export const createVote = async (c: Context<AppEnv>) => {
return c.json({ success: false, message: 'Invalid JSON body' }, 400)
}

const { stallId, rating } = body
const { itemId, rating } = body

if (typeof stallId !== 'number' || typeof rating !== 'number') {
return c.json({ success: false, message: 'stallId and rating must be numbers' }, 400)
if (typeof itemId !== 'number' || typeof rating !== 'number') {
return c.json({ success: false, message: 'itemId and rating must be numbers' }, 400)
}

if (rating < 0 || rating > 10) {
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const users = sqliteTable('users', {
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});

export const stalls = sqliteTable('stalls', {
export const items = sqliteTable('items', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
description: text('description'),
Expand All @@ -25,11 +25,11 @@ export const stalls = sqliteTable('stalls', {
export const ratings = sqliteTable('ratings', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
stallId: integer('stall_id').notNull().references(() => stalls.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.stallId),
unq: uniqueIndex('unique_vote').on(table.userId, table.itemId),
})
);
4 changes: 2 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { AppEnv } from './types'
import voteRoutes from './routes/vote.route'
import healthRoutes from './routes/health.route'
import progressRoutes from './routes/progress.route'
import stallsRoutes from './routes/stalls.route'
import itemsRoutes from './routes/items.route'
import resultsRoutes from './routes/results.route'
import userRoutes from './routes/user.route'
import webhookRoutes from './routes/webhook.route'
Expand Down Expand Up @@ -80,7 +80,7 @@ const api = app.basePath('/api/v1')
api.route('/vote', voteRoutes)
api.route('/health', healthRoutes)
api.route('/progress', progressRoutes)
api.route('/stalls', stallsRoutes)
api.route('/items', itemsRoutes)
api.route('/results', resultsRoutes)
api.route('/user', userRoutes)

Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/routes/items.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { getItemBySlug } from '../controllers/items.Controller'

const items = new Hono<AppEnv>()

items.get('/:slug', getItemBySlug)

export default items
14 changes: 7 additions & 7 deletions apps/api/src/routes/results.route.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { Hono } from 'hono';
import { getDb } from '../db/client';
import { stalls, ratings } from '../db/schema';
import { items, ratings } from '../db/schema';
import { eq } from 'drizzle-orm';
import type { AppEnv } from '../types';
import { refreshStallAggregates } from '../services/stalls.Service';
import { refreshItemAggregates } from '../services/items.Service';
const resultsRoutes = new Hono<AppEnv>();

resultsRoutes.get('/', async (c) => {
try {
const db = getDb(c.env.DB);
const allStalls = await db.select().from(stalls);
const stallIds = allStalls.map(s => s.id);
const allItems = await db.select().from(items);
const itemIds = allItems.map(i => i.id);

// Refresh all stalls to ensure consistency (optional but good for a "Results" view)
// Refresh all items to ensure consistency (optional but good for a "Results" view)
// In a high-traffic app, we might skip this and just read, but for this event, real-time-consistent view is best.
await refreshStallAggregates(c.env.DB, stallIds);
await refreshItemAggregates(c.env.DB, itemIds);

// Fetch the updated data
const finalResults = await db.select().from(stalls);
const finalResults = await db.select().from(items);

// Sort by qualifiedRatingSum descending for the leaderboard
finalResults.sort((a, b) => (b.qualifiedRatingSum || 0) - (a.qualifiedRatingSum || 0));
Expand Down
9 changes: 0 additions & 9 deletions apps/api/src/routes/stalls.route.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,37 +1,37 @@
import { getDb } from '../db/client'
import { stalls, ratings } from '../db/schema'
import { items, ratings } from '../db/schema'
import { eq, inArray, count } from 'drizzle-orm'

export const fetchStall = async (db: D1Database, slug: string) => {
export const fetchItem = async (db: D1Database, slug: string) => {
const ormDb = getDb(db)
const result = await ormDb.select().from(stalls).where(eq(stalls.qrSlug, slug)).limit(1)
const result = await ormDb.select().from(items).where(eq(items.qrSlug, slug)).limit(1)
return result[0] || null
}

export const refreshStallAggregates = async (db: D1Database, stallIds: number[]) => {
if (stallIds.length === 0) return;
export const refreshItemAggregates = async (db: D1Database, itemIds: number[]) => {
if (itemIds.length === 0) return;
const ormDb = getDb(db);

// 1. Get all ratings for these stalls
// 1. Get all ratings for these items
const targetRatings = await ormDb
.select()
.from(ratings)
.where(inArray(ratings.stallId, stallIds));
.where(inArray(ratings.itemId, itemIds));

// 2. Get qualification status for all users who voted for these stalls
// 2. Get qualification status for all users who voted for these items
const userIds = [...new Set(targetRatings.map(r => r.userId))];
if (userIds.length === 0) {
// If no ratings left, reset stalls (unlikely in this context but good for robustness)
for (const id of stallIds) {
await ormDb.update(stalls)
// If no ratings left, reset items (unlikely in this context but good for robustness)
for (const id of itemIds) {
await ormDb.update(items)
.set({
totalVoters: 0,
qualifiedVoters: 0,
qualifiedRatingSum: 0,
nonQualifiedRatingSum: 0,
qualifiedAvgRating: 0
})
.where(eq(stalls.id, id));
.where(eq(items.id, id));
}
return;
}
Expand All @@ -51,15 +51,15 @@ export const refreshStallAggregates = async (db: D1Database, stallIds: number[])
.map(u => u.userId)
);

// 3. Aggregate per stall
// 3. Aggregate per item
const aggregates = new Map<number, {
totalVoters: number;
qualifiedVoters: number;
qualifiedRatingSum: number;
nonQualifiedRatingSum: number;
}>();

for (const id of stallIds) {
for (const id of itemIds) {
aggregates.set(id, {
totalVoters: 0,
qualifiedVoters: 0,
Expand All @@ -69,8 +69,8 @@ export const refreshStallAggregates = async (db: D1Database, stallIds: number[])
}

for (const rating of targetRatings) {
if (!rating.stallId) continue;
const agg = aggregates.get(rating.stallId);
if (!rating.itemId) continue;
const agg = aggregates.get(rating.itemId);
if (!agg) continue;

agg.totalVoters += 1;
Expand All @@ -83,20 +83,20 @@ export const refreshStallAggregates = async (db: D1Database, stallIds: number[])
}

// 4. Update the database
await Promise.all(stallIds.map(async (id) => {
await Promise.all(itemIds.map(async (id) => {
const agg = aggregates.get(id)!;
const qualifiedAvgRating = agg.qualifiedVoters > 0
? Math.round((agg.qualifiedRatingSum / agg.qualifiedVoters) * 100) / 100
const qualifiedAvgRating = agg.qualifiedVoters > 0
? Math.round((agg.qualifiedRatingSum / agg.qualifiedVoters) * 100) / 100
: 0;

await ormDb.update(stalls)
await ormDb.update(items)
.set({
totalVoters: agg.totalVoters,
qualifiedVoters: agg.qualifiedVoters,
qualifiedRatingSum: agg.qualifiedRatingSum,
nonQualifiedRatingSum: agg.nonQualifiedRatingSum,
qualifiedAvgRating: qualifiedAvgRating,
})
.where(eq(stalls.id, id));
.where(eq(items.id, id));
}));
}
8 changes: 4 additions & 4 deletions apps/api/src/services/progress.Service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getDb } from '../db/client'
import { ratings, users, stalls } from '../db/schema'
import { ratings, users, items } from '../db/schema'
import { eq, count } from 'drizzle-orm'

export const fetchProgress = async (db: D1Database, userId: string) => {
Expand All @@ -13,12 +13,12 @@ export const fetchProgress = async (db: D1Database, userId: string) => {

const userRatings = await ormDb
.select({
stallId: stalls.id,
stallName: stalls.name,
itemId: items.id,
itemName: items.name,
rating: ratings.rating
})
.from(ratings)
.innerJoin(stalls, eq(ratings.stallId, stalls.id))
.innerJoin(items, eq(ratings.itemId, items.id))
.where(eq(ratings.userId, userId))

return { userId, progress: progressCount, isCompleted, ratings: userRatings }
Expand Down
22 changes: 11 additions & 11 deletions apps/api/src/services/vote.Service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ratings, users } from '../db/schema'
import { eq, count } from 'drizzle-orm'
import type { AppEnv } from '../types'
import { ensureUserExists } from './user.Service'
import { refreshStallAggregates } from './stalls.Service'
import { refreshItemAggregates } from './items.Service'
function isVotingOpen(): boolean {
const istOffset = 5.5 * 60 * 60 * 1000;
const istTime = new Date(Date.now() + istOffset);
Expand Down Expand Up @@ -42,15 +42,15 @@ export const submitVote = async (
throw new Error('Voting is currently closed');
}

const { stallId, rating } = vote
const { itemId, rating } = vote
const ormDb = getDb(env.DB)

try {
// 1. Ensure whether the user exists
await ensureUserExists(env, userId)

// 2. Now proceed with the vote
await ormDb.insert(ratings).values({ userId, stallId, rating })
await ormDb.insert(ratings).values({ userId, itemId, rating })
} catch (e: any) {
if (e.message && e.message.includes('UNIQUE constraint failed')) {
throw new Error('Already voted')
Expand All @@ -74,22 +74,22 @@ export const submitVote = async (
.where(eq(users.id, userId))
}

// Update stall aggregates immediately
// Update item aggregates immediately
if (progressCount === 11) {
// User just became qualified! All their stalls need refreshing
// User just became qualified! All their items need refreshing
const userRatings = await ormDb
.select({ stallId: ratings.stallId })
.select({ itemId: ratings.itemId })
.from(ratings)
.where(eq(ratings.userId, userId));

const stallIds = userRatings
.map(r => r.stallId)
const itemIds = userRatings
.map(r => r.itemId)
.filter((id): id is number => id !== null);

await refreshStallAggregates(env.DB, stallIds);
await refreshItemAggregates(env.DB, itemIds);
} else {
// Normal vote (could be qualified or not, but only this stall is affected)
await refreshStallAggregates(env.DB, [stallId]);
// Normal vote (could be qualified or not, but only this item is affected)
await refreshItemAggregates(env.DB, [itemId]);
}

return progressCount
Expand Down
Loading
Loading