feat: anonymous questions and answers with upvotes - #130
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new anonymous Q&A domain to the backend (questions, answers, and upvotes), including write restrictions to verified members and read endpoints that return per-caller UI flags (owned, upvoted) without exposing authors.
Changes:
- Introduces Q&A service + REST controller with create/read/update/delete for questions/answers and upvote toggling.
- Adds new JPA entities and repositories for questions, answers, and question upvotes (including denormalized upvote counter updates).
- Implements per-user spam cooldowns via conditional
UPDATEqueries on theuserstable.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/com/pecacm/backend/services/QnaService.java | Core Q&A business logic: CRUD, upvotes, cooldowns, and caller-specific flags. |
| src/main/java/com/pecacm/backend/controllers/QnaController.java | Exposes the Q&A REST endpoints under /v1. |
| src/main/java/com/pecacm/backend/entities/Question.java | New questions entity with transient owned/upvoted response flags. |
| src/main/java/com/pecacm/backend/entities/Answer.java | New answers entity with transient owned response flag. |
| src/main/java/com/pecacm/backend/entities/QuestionUpvote.java | New question_upvotes join entity enforcing one upvote per (question,user). |
| src/main/java/com/pecacm/backend/repository/QuestionRepository.java | Question paging query and atomic counter increment/decrement updates. |
| src/main/java/com/pecacm/backend/repository/AnswerRepository.java | Answer paging query and bulk delete by question. |
| src/main/java/com/pecacm/backend/repository/QuestionUpvoteRepository.java | Upvote existence/delete helpers and batched “upvoted IDs” query. |
| src/main/java/com/pecacm/backend/repository/UserRepository.java | Adds conditional update queries to claim cooldown slots. |
| src/main/java/com/pecacm/backend/entities/User.java | Adds last_question_date / last_answer_date fields for cooldown tracking. |
| src/main/java/com/pecacm/backend/model/QnaRequest.java | Shared request body model for question/answer content. |
| src/main/java/com/pecacm/backend/constants/ErrorConstants.java | Adds Q&A-specific error messages. |
| src/main/java/com/pecacm/backend/constants/Constants.java | Adds cooldown constants and uses ANONYMOUS role for “public” endpoints. |
| .gitignore | Ignores secret.json credential files. |
Suppressed comments (2)
src/main/java/com/pecacm/backend/entities/QuestionUpvote.java:37
- Same as the
questionassociation: theuserlink should beLAZYand non-nullable so an upvote row cannot exist without a user and to avoid unnecessary eager loads.
@ManyToOne
@JoinColumn(name = "user_id")
@JsonIgnore
private User user;
src/main/java/com/pecacm/backend/controllers/QnaController.java:95
- Same issue as above: the check rejects
pageSize <= 0but the message says>= 0. Align the message with the actual constraint.
if (pageSize <= 0) throw new AcmException("pageSize must be >= 0", HttpStatus.BAD_REQUEST);
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| throw new AcmException(ErrorConstants.QUESTION_NOT_UPVOTED, HttpStatus.CONFLICT); | ||
| } | ||
|
|
||
| questionUpvoteRepository.deleteByQuestionIdAndUserId(questionId, user.getId()); | ||
| questionRepository.decrementUpvotes(questionId); |
| @ManyToOne | ||
| @JoinColumn(name = "question_id") | ||
| @JsonIgnore | ||
| private Question question; |
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "user_id") | ||
| @JsonIgnore | ||
| private User askedBy; |
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "question_id") | ||
| @JsonIgnore | ||
| private Question question; |
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "user_id") | ||
| @JsonIgnore | ||
| private User answeredBy; |
| if (pageSize == null) pageSize = 20; // returning first 20 questions | ||
|
|
||
| if (offset < 0) throw new AcmException("offset cannot be < 0", HttpStatus.BAD_REQUEST); | ||
| if (pageSize <= 0) throw new AcmException("pageSize must be >= 0", HttpStatus.BAD_REQUEST); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/main/java/com/pecacm/backend/services/QnaService.java:204
markOwnedperformsuserRepository.findByEmail(email)unconditionally; on public reads this is called for anonymous users too, so it results in a needless DB lookup per request and will also throw ifemailis null. Short-circuit when there is no caller identity before querying the user table.
private List<Answer> markOwned(List<Answer> answers, String email) {
Optional<User> user = userRepository.findByEmail(email);
if (user.isEmpty() || answers.isEmpty()) {
return answers;
}
src/main/java/com/pecacm/backend/services/QnaService.java:184
markCallerStateperformsuserRepository.findByEmail(email)unconditionally; on public reads this is called for anonymous users too, so it results in a needless DB lookup per request and will also throw ifemailis null. Short-circuit when there is no caller identity before querying the user table.
This issue also appears on line 200 of the same file.
private List<Question> markCallerState(List<Question> questions, String email) {
Optional<User> user = userRepository.findByEmail(email);
if (user.isEmpty() || questions.isEmpty()) {
return questions;
}
src/main/java/com/pecacm/backend/services/QnaService.java:107
- Catching
DataIntegrityViolationExceptionand always returningQUESTION_ALREADY_UPVOTEDcan misreport other integrity errors (e.g. the question being deleted concurrently, or other DB constraint failures) as a 409 conflict. Narrow the conflict case to when an upvote row actually exists, and surface a more accurate error otherwise.
} catch (DataIntegrityViolationException ex) {
// two upvotes racing each other, unique constraint decides the winner
throw new AcmException(ErrorConstants.QUESTION_ALREADY_UPVOTED, HttpStatus.CONFLICT);
}
src/main/java/com/pecacm/backend/controllers/QnaController.java:117
getLoggedInEmail()always returns the Security principal, even for anonymous callers. Since the GET endpoints are accessible to anonymous users, this value can be non-email (anonymous principal) and later triggers unnecessary user lookups when computingowned/upvoted. Consider returningnullfor anonymous callers (and using.toString()instead of a hard cast) so the service can skip caller-specific computation without DB access.
private String getLoggedInEmail() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return (String) authentication.getPrincipal();
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/main/java/com/pecacm/backend/controllers/QnaController.java:121
- getLoggedInEmail() uses authentication.getName(), while other controllers (e.g., UserController) treat authentication.getPrincipal() as the String email. Using getPrincipal() here keeps the controller consistent with the rest of the codebase and avoids surprises if Authentication implementations change.
return authentication.getName();
| public void deleteQuestion(Integer questionId, String email) { | ||
| Question question = getQuestion(questionId); | ||
| verifyAskedBy(question, email); | ||
|
|
||
| questionUpvoteRepository.deleteAllByQuestion(question); | ||
| answerRepository.deleteAllByQuestion(question); | ||
| questionRepository.delete(question); |
| public static final String UPDATE_SUCCESS = "Successfully Updated"; | ||
|
|
||
| /* Cooldown between two posts by the same user, to prevent spam */ | ||
| public static final int QUESTION_COOLDOWN_MINUTES = 15; |
There was a problem hiding this comment.
"fifteen minutes" sounds a little too low to me?
maybe bump it up to an hour?
| import java.util.List; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/v1") |
There was a problem hiding this comment.
should we drop versioned api routes?
since we don't publish major api changes, feels redundant to me to keep it.
There was a problem hiding this comment.
I tried this, then reverted it. Every other controller uses /v1 (/v1/user, /v1/events, /v1/support, /v1/email); only HealthController is unversioned, which is standard for health checks. Dropping it just for Q&A leaves the API half-versioned rather than less redundant.
I agree the prefix is redundant if we never version , but doing that properly means stripping it everywhere, which is breaking, since the frontend hardcodes /v1 paths. That needs to ship alongside a website update, so I'd rather raise it as its own PR than land it here. Kept /v1 for now , happy to change if you'd still prefer otherwise.
| @Column(name = "upvotes", nullable = false) | ||
| private Integer upvotes = 0; |
There was a problem hiding this comment.
this column feels redundant as we can always compute the number of updates from upvote details
There was a problem hiding this comment.
Dropped. The count is now derived from question_upvotes, and ordering uses COUNT(...) DESC instead of a stored total. This also removed the increment/decrement paths entirely, so counter-vs-ledger drift is now impossible rather than just guarded , it actually resolved two earlier review findings as a side effect.
| @CreationTimestamp | ||
| @Column(name = "created_date") | ||
| private LocalDateTime createdDate; |
There was a problem hiding this comment.
same nitpick as the other comment
There was a problem hiding this comment.
Done, same rename applied here.
| @CreationTimestamp | ||
| @Column(name = "created_date") | ||
| private LocalDateTime createdDate; |
There was a problem hiding this comment.
just a nitpick:
created_at feels better if we are storing full timestamps instead of just the date.
There was a problem hiding this comment.
Renamed on all three entities : questions, answers and upvotes. Payloads now return createdAt.
| // kept on the user so that deleting a post cannot reset the spam cooldown | ||
| @Column(name = "last_question_date") | ||
| @JsonIgnore | ||
| private LocalDateTime lastQuestionDate; | ||
|
|
||
| @Column(name = "last_answer_date") | ||
| @JsonIgnore | ||
| private LocalDateTime lastAnswerDate; | ||
|
|
There was a problem hiding this comment.
not sure how I feel about this being properties on the User object
There was a problem hiding this comment.
Agreed , moved into its own qna_cooldowns table keyed by user_id, so the Q&A feature owns its own state. User.java and UserRepository.java are now byte-identical to main; this PR no longer touches the users table at all.
The atomic claim survived the move via an upsert (ON CONFLICT (user_id) DO UPDATE ... WHERE cooldown elapsed), so it's still a single statement , 10 concurrent requests still result in exactly one question being created.
Title:
feat: anonymous questions and answers with upvotes
Description:
Adds an anonymous Q&A feature: verified members can post questions, answer them,
and upvote. The author is stored in the database for permission checks but is
never returned to the client.
Endpoints
Every request body is
{"content": "..."}. Reads are public, writes require averified member.
Response shapes — the author never appears:
upvotedandownedare computed per caller (not persisted) so the frontend canrender the upvote toggle and show edit/delete controls only on the caller's own
posts. Both are
falsefor anonymous readers.Status codes:
400blank content ·403not the author / unverified / no token ·404not found ·409upvote state conflict ·429cooldown.Design decisions
Anonymity —
askedBy/answeredByare@JsonIgnored, so the author ispersisted for permission checks but never serialized.
One upvote per user — a
question_upvotestable with a unique constraint on(question_id, user_id). The constraint is the actual guard; the service check infront of it only exists to return a friendlier error. The counter on
questionsis updated with
SET upvotes = upvotes + 1in a single statement so concurrentupvotes are not lost.
Spam cooldowns — 15 minutes between questions, 2 minutes between answers.
The slot is claimed with one conditional
UPDATEon the user row rather than aread-then-write, which makes it immune to a race, and storing it on the user means
deleting your own post cannot reset the window.
Verified members only — login already rejects unverified users, but a JWT stays
valid for 30 days after it is issued, so
verifiedis re-checked on every write.Cascade — deleting a question removes its answers and upvotes first, so nothing
is left orphaned behind a question id that no longer exists.
Schema
ddl-auto: updatehandles this, no migration needed:questions,answers,question_upvotesuserstable:last_question_date,last_answer_date(both@JsonIgnored, confirmed absent from every existinguser-returning endpoint)
Testing
Tested locally against a Dockerised Postgres:
Follow-ups, deliberately not in this PR
abusive anonymous posts. Worth adding given the feature is anonymous.
skipTests=trueand a single test class,so this PR matches that. Happy to add
@SpringBootTestcoverage of the ownershipand cooldown logic if preferred.
offsetbehaves as a page index rather than a row offset, andpageSizeisunbounded — both match the existing
EventsController/UserController, sothis PR stays consistent rather than diverging.