Skip to content

Commit 4ca8d6a

Browse files
authored
Merge pull request #40 from CMU-17313Q/feat/polls-get-by-post-clean
feat(polls): add GET /api/posts/:pid/poll (Issue 3)
2 parents 0adc6b3 + c535e7c commit 4ca8d6a

File tree

11 files changed

+475
-18
lines changed

11 files changed

+475
-18
lines changed

public/openapi/read.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ paths:
256256
$ref: 'read/email/unsubscribe/token.yaml'
257257
"/api/post/{pid}":
258258
$ref: 'read/post/pid.yaml'
259+
"/api/posts/{pid}/poll":
260+
$ref: 'read/posts/pid/poll.yaml'
259261
/api/flags:
260262
$ref: 'read/flags.yaml'
261263
"/api/flags/{flagId}":
@@ -367,4 +369,4 @@ paths:
367369
/api/ap:
368370
$ref: 'read/ap.yaml'
369371
/api/outgoing:
370-
$ref: 'read/outgoing.yaml'
372+
$ref: 'read/outgoing.yaml'

public/openapi/read/posts.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/posts/{pid}:
2+
# ...existing code...
3+
4+
/posts/{pid}/poll:
5+
$ref: './posts/poll.yaml#/get'
6+
7+
# ...existing code...
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
get:
2+
tags:
3+
- posts
4+
summary: Get poll for a specific post
5+
parameters:
6+
- in: path
7+
name: pid
8+
schema:
9+
type: integer
10+
required: true
11+
description: Post ID
12+
example: 1
13+
responses:
14+
"200":
15+
description: Poll data for the post
16+
content:
17+
application/json:
18+
schema:
19+
type: object
20+
properties:
21+
postId:
22+
type: integer
23+
description: The post ID
24+
poll:
25+
oneOf:
26+
- type: object
27+
additionalProperties: false
28+
description: Empty poll object when no poll exists
29+
- type: object
30+
description: Poll data when poll exists
31+
properties:
32+
id:
33+
type: string
34+
description: Poll ID
35+
question:
36+
type: string
37+
description: Poll question
38+
options:
39+
type: array
40+
items:
41+
type: object
42+
properties:
43+
index:
44+
type: integer
45+
text:
46+
type: string
47+
hasVoted:
48+
type: boolean
49+
description: Whether the current user has voted
50+
selectedOptionIndex:
51+
type: integer
52+
nullable: true
53+
description: Index of the option the user selected
54+
counts:
55+
type: array
56+
nullable: true
57+
items:
58+
type: integer
59+
description: Vote counts for each option (shown after voting)
60+
totalVotes:
61+
type: integer
62+
nullable: true
63+
description: Total number of votes (shown after voting)
64+
"400":
65+
description: Bad request (invalid post ID)
66+
content:
67+
application/json:
68+
schema:
69+
type: object
70+
properties:
71+
error:
72+
type: string
73+
example: "bad pid"
74+
"404":
75+
description: Poll not found for this post
76+
content:
77+
application/json:
78+
schema:
79+
type: object
80+
properties:
81+
postId:
82+
type: integer
83+
poll:
84+
type: object
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
get:
2+
tags:
3+
- Posts
4+
summary: Get poll data for a post
5+
description: Retrieve poll information for a specific post, including voting status and results (if allowed)
6+
parameters:
7+
- name: pid
8+
in: path
9+
required: true
10+
description: Post ID
11+
schema:
12+
type: string
13+
responses:
14+
'200':
15+
description: Poll data retrieved successfully
16+
content:
17+
application/json:
18+
schema:
19+
type: object
20+
properties:
21+
status:
22+
type: object
23+
properties:
24+
code:
25+
type: string
26+
example: ok
27+
message:
28+
type: string
29+
example: OK
30+
response:
31+
oneOf:
32+
- type: object
33+
description: Empty object when post has no poll
34+
example: {}
35+
- type: object
36+
description: Poll data when post has a poll
37+
properties:
38+
question:
39+
type: string
40+
example: "What is your favorite color?"
41+
options:
42+
type: array
43+
items:
44+
type: object
45+
properties:
46+
title:
47+
type: string
48+
example: "Blue"
49+
count:
50+
type: integer
51+
description: Vote count (only included if user has voted or results are visible)
52+
example: 5
53+
hasVoted:
54+
type: boolean
55+
example: true
56+
selectedOptionIndex:
57+
type: integer
58+
description: Index of user's selected option (only if hasVoted is true)
59+
example: 0
60+
totalVotes:
61+
type: integer
62+
description: Total number of votes (only included if user has voted or results are visible)
63+
example: 15
64+
'404':
65+
description: Post not found
66+
content:
67+
application/json:
68+
schema:
69+
type: object
70+
properties:
71+
status:
72+
type: object
73+
properties:
74+
code:
75+
type: string
76+
example: not-found
77+
message:
78+
type: string
79+
example: Post not found
Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
put:
22
tags:
3-
- files
4-
summary: create a new folder
5-
description: This operation creates a new folder inside upload path
3+
- Files
4+
summary: Create a new folder
5+
description: This operation creates a new folder inside the upload path.
66
requestBody:
77
required: true
88
content:
@@ -12,7 +12,7 @@ put:
1212
properties:
1313
path:
1414
type: string
15-
description: Path to the file (relative to the configured `upload_path`)
15+
description: Path relative to the configured `upload_path`
1616
example: /files
1717
folderName:
1818
type: string
@@ -23,14 +23,31 @@ put:
2323
- folderName
2424
responses:
2525
'200':
26-
description: Folder created
26+
description: Folder created successfully
2727
content:
2828
application/json:
2929
schema:
3030
type: object
3131
properties:
3232
status:
33-
$ref: ../../components/schemas/Status.yaml#/Status
33+
type: object
34+
properties:
35+
code:
36+
type: string
37+
example: ok
38+
message:
39+
type: string
40+
example: OK
3441
response:
3542
type: object
36-
properties: {}
43+
description: Additional response data
44+
'403':
45+
description: Forbidden
46+
content:
47+
application/json:
48+
schema:
49+
type: object
50+
properties:
51+
error:
52+
type: string
53+
example: forbidden

src/polls/model.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use strict';
2+
3+
const db = require.main.require('./src/database');
4+
const { v4: uuid } = require('uuid');
5+
6+
const PollModel = {
7+
/**
8+
* Create a poll and index it by postId.
9+
* @param {Object} p
10+
* @param {string|number} p.postId
11+
* @param {string} p.question
12+
* @param {string[]} p.options - at least 2
13+
* @param {string|number} p.createdBy
14+
* @param {number|null} [p.closesAt]
15+
*/
16+
async createPoll({ postId, question, options, createdBy, closesAt = null }) {
17+
if (!postId || !question || !Array.isArray(options) || options.length < 2) {
18+
throw new Error('Invalid poll: need postId, question, and at least 2 options');
19+
}
20+
21+
const pollId = uuid();
22+
23+
const pollKey = `poll:${pollId}`;
24+
const data = {
25+
pollId,
26+
postId: String(postId),
27+
question: String(question),
28+
options: JSON.stringify(options),
29+
createdBy: String(createdBy ?? ''),
30+
createdAt: String(Date.now()),
31+
closesAt: closesAt ? String(closesAt) : '',
32+
};
33+
34+
// Store main poll object
35+
await db.setObject(pollKey, data);
36+
37+
// Index polls by post (future-proof if you ever allow >1 poll/post)
38+
await db.setAdd(`poll:byPost:${postId}`, pollId);
39+
40+
// Initialize counts for each option to 0
41+
const counts = {};
42+
options.forEach((_, idx) => { counts[idx] = 0; });
43+
await db.setObject(`poll:counts:${pollId}`, counts);
44+
45+
return { ...data, options };
46+
},
47+
48+
async getPollById(pollId) {
49+
const obj = await db.getObject(`poll:${pollId}`);
50+
if (!obj || !obj.pollId) return null;
51+
return { ...obj, options: JSON.parse(obj.options || '[]') };
52+
},
53+
54+
async getPollIdsByPostId(postId) {
55+
return db.getSetMembers(`poll:byPost:${postId}`);
56+
},
57+
58+
// Common case: one poll per post — return the first if it exists
59+
async getPollByPostId(postId) {
60+
const ids = await db.getSetMembers(`poll:byPost:${postId}`);
61+
if (!ids || !ids.length) return null;
62+
return this.getPollById(ids[0]);
63+
},
64+
};
65+
66+
module.exports = PollModel;

src/polls/votes.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use strict';
2+
3+
const db = require.main.require('./src/database');
4+
5+
const Votes = {
6+
/**
7+
* Check if a user already voted in a poll
8+
*/
9+
async hasUserVoted(pollId, userId) {
10+
return db.isSetMember(`poll:voters:${pollId}`, String(userId));
11+
},
12+
13+
/**
14+
* Record a vote and increment counts.
15+
* Throws { code: 'ALREADY_VOTED' } if same user votes again.
16+
*/
17+
async recordVote({ pollId, userId, optionIndex }) {
18+
if (!pollId || !userId || typeof optionIndex !== 'number' || Number.isNaN(optionIndex)) {
19+
const e = new Error('Missing pollId/userId or bad optionIndex');
20+
e.code = 'BAD_INPUT';
21+
throw e;
22+
}
23+
24+
// Uniqueness: check first, then add
25+
const already = await db.isSetMember(`poll:voters:${pollId}`, String(userId));
26+
if (already) {
27+
const err = new Error('User has already voted on this poll');
28+
err.code = 'ALREADY_VOTED';
29+
throw err;
30+
}
31+
await db.setAdd(`poll:voters:${pollId}`, String(userId));
32+
33+
// Optional: store the user’s choice
34+
await db.setObject(`poll:vote:${pollId}:${userId}`, {
35+
optionIndex: String(optionIndex),
36+
createdAt: String(Date.now()),
37+
});
38+
39+
// Increment aggregate count for the chosen option
40+
await db.incrObjectField(`poll:counts:${pollId}`, String(optionIndex), 1);
41+
return true;
42+
},
43+
44+
/**
45+
* Get aggregate counts for all options in a poll
46+
*/
47+
async getCounts(pollId) {
48+
const counts = await db.getObject(`poll:counts:${pollId}`) || {};
49+
const out = {};
50+
for (const k of Object.keys(counts)) {
51+
out[k] = Number(counts[k]);
52+
}
53+
return out;
54+
},
55+
56+
/**
57+
* Get the option a specific user voted for
58+
*/
59+
async getUserChoice(pollId, userId) {
60+
const obj = await db.getObject(`poll:vote:${pollId}:${userId}`);
61+
if (!obj || typeof obj.optionIndex === 'undefined') {
62+
return null;
63+
}
64+
return Number(obj.optionIndex);
65+
},
66+
};
67+
68+
module.exports = Votes;

0 commit comments

Comments
 (0)