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
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "web", "run", "dev"],
"port": 5173
}
]
}
6 changes: 6 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ NODE_ENV=development
JWT_SECRET=replace-with-a-long-random-string
SENDGRID_API_KEY=API_KEY_HERE
SENDGRID_FROM_EMAIL=mail@domain.com
# This API's own origin - used to build the verification link, which is a
# GET route this server handles directly (/api/auth/verify).
APP_URL=http://localhost:5001
# The frontend's origin (Vite dev server, or the deployed web app) - used to
# build the password reset link, which is a client-side route the React app
# handles (/reset-password).
WEB_APP_URL=http://localhost:5173
# comma-separated allowed web origins leave empty to allow all
CORS_ORIGINS=
AWS_REGION=us-east-1
Expand Down
30 changes: 0 additions & 30 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions api/src/confidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export function applyVote(E: number, type: VoteType): number {
return E + VOTE_WEIGHT[type];
}

export function reverseVote(E: number, type: VoteType): number {
return E - VOTE_WEIGHT[type];
}

export function decayE(E: number, minutesElapsed: number): number {
return E_PRIOR + (E - E_PRIOR) * Math.exp(-LAMBDA * minutesElapsed);
}
Expand Down
2 changes: 2 additions & 0 deletions api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface Config {
sendgridApiKey: string;
sendgridFromEmail: string;
appUrl: string;
webAppUrl: string;
corsOrigins: string[];
awsRegion: string;
s3Bucket: string;
Expand All @@ -28,6 +29,7 @@ const config: Config = {
sendgridApiKey: required('SENDGRID_API_KEY'),
sendgridFromEmail: required('SENDGRID_FROM_EMAIL'),
appUrl: process.env.APP_URL ?? 'http://localhost:5001',
webAppUrl: process.env.WEB_APP_URL ?? 'http://localhost:5173',
corsOrigins: (process.env.CORS_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean),
awsRegion: process.env.AWS_REGION ?? 'us-east-1',
s3Bucket: process.env.S3_BUCKET ?? '',
Expand Down
2 changes: 1 addition & 1 deletion api/src/services/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function sendVerificationEmail(to: string, rawToken: string): Promi
}

export async function sendPasswordResetEmail(to: string, rawToken: string): Promise<void> {
const link = `${config.appUrl}/reset-password?token=${rawToken}`; // needs frontend page
const link = `${config.webAppUrl}/reset-password?token=${rawToken}`;

await sgMail.send({
to,
Expand Down
37 changes: 32 additions & 5 deletions api/src/services/posts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Post, type IPost, type VoteType } from '../models/Post.js';
import { applyVote, decayE, sigmoid, statusFromConfidence, expiryFromE, E_INITIAL } from '../confidence.js';
import { applyVote, reverseVote, decayE, sigmoid, statusFromConfidence, expiryFromE, E_INITIAL } from '../confidence.js';
import { AppError } from '../errors.js';

type NewPost = Pick<IPost, 'foodName' | 'location' | 'badges' | 'imageKey'>;
Expand Down Expand Up @@ -27,15 +27,42 @@ export async function vote(postId: string, userId: string, type: VoteType) {
throw new AppError(404, 'Post not found');
}

if (post.votes.some((v) => v.user.toString() === userId)) {
throw new AppError(409, 'You have already voted on this post');
const existingVote = post.votes.find((v) => v.user.toString() === userId);
const minutes = (now.getTime() - post.lastUpdate.getTime()) / 60000;
const decayed = decayE(post.E, minutes);

// Re-selecting the same option is a no-op - nothing to change.
if (existingVote && existingVote.type === type) {
const confidence = sigmoid(decayed);
return { confidence, status: statusFromConfidence(confidence), tallies: post.tallies };
}

const minutes = (now.getTime() - post.lastUpdate.getTime()) / 60000;
const E = applyVote(decayE(post.E, minutes), type);
const E = existingVote ? applyVote(reverseVote(decayed, existingVote.type), type) : applyVote(decayed, type);
const confidence = sigmoid(E);
const status = statusFromConfidence(confidence);
const expiresAt = expiryFromE(E, now);

if (existingVote) {
// Switching an existing vote to the other option: move the tally over.
const tallies = {
present: post.tallies.present + (type === 'present' ? 1 : 0) - (existingVote.type === 'present' ? 1 : 0),
gone: post.tallies.gone + (type === 'gone' ? 1 : 0) - (existingVote.type === 'gone' ? 1 : 0),
};

const res = await Post.updateOne(
{ _id: postId, 'votes.user': userId, 'votes.type': existingVote.type },
{
$set: { 'votes.$.type': type, 'votes.$.at': now, E, status, lastUpdate: now, expiresAt },
$inc: { [`tallies.${existingVote.type}`]: -1, [`tallies.${type}`]: 1 },
},
);
if (res.matchedCount === 0) {
throw new AppError(409, 'Your vote changed elsewhere, please try again');
}

return { confidence, status, tallies };
}

const tallies = {
present: post.tallies.present + (type === 'present' ? 1 : 0),
gone: post.tallies.gone + (type === 'gone' ? 1 : 0),
Expand Down
22 changes: 20 additions & 2 deletions api/test/posts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,30 @@ describe('POST /api/posts/:id/vote', () => {
expect(res.body.confidence).toBeGreaterThan(0.9);
});

it('rejects a second vote from the same user with 409', async () => {
it('lets a user switch their vote to the other option', async () => {
const token = await authUser();
const { body } = await createPost(token);
await vote(token, body.post._id, 'present').expect(200);
const res = await vote(token, body.post._id, 'gone');
expect(res.status).toBe(409);
expect(res.status).toBe(200);
expect(res.body.tallies).toEqual({ present: 0, gone: 1 });
expect(res.body.status).toBe('fading');

const votes = (await Post.findById(body.post._id).select('+votes'))?.votes;
expect(votes).toHaveLength(1);
expect(votes?.[0]).toMatchObject({ type: 'gone' });
});

it('re-casting the same vote is a no-op', async () => {
const token = await authUser();
const { body } = await createPost(token);
await vote(token, body.post._id, 'present').expect(200);
const res = await vote(token, body.post._id, 'present');
expect(res.status).toBe(200);
expect(res.body.tallies).toEqual({ present: 1, gone: 0 });

const votes = (await Post.findById(body.post._id).select('+votes'))?.votes;
expect(votes).toHaveLength(1);
});

it('lets two different users each vote once', async () => {
Expand Down
Loading