Skip to content

feat: anonymous questions and answers with upvotes - #130

Merged
PECACM merged 6 commits into
PEC-CSS:mainfrom
Kanavpreet-Singh:feat-anonymous-questions
Aug 23, 2026
Merged

PECACM merged 6 commits into
PEC-CSS:mainfrom
Kanavpreet-Singh:feat-anonymous-questions

Conversation

@Kanavpreet-Singh

Copy link
Copy Markdown
Collaborator

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

POST   /v1/questions                        create question       verified member
GET    /v1/questions                        list (paged)          public
GET    /v1/questions/{id}                   single                public
PUT    /v1/questions/{id}                   edit                  author only
DELETE /v1/questions/{id}                   delete                author only
POST   /v1/questions/{id}/upvote            upvote                verified member
DELETE /v1/questions/{id}/upvote            remove upvote         verified member

POST   /v1/questions/{questionId}/answers   create answer         verified member
GET    /v1/questions/{questionId}/answers   list (paged)          public
PUT    /v1/answers/{answerId}               edit                  author only
DELETE /v1/answers/{answerId}               delete                author only

Every request body is {"content": "..."}. Reads are public, writes require a
verified member.

Response shapes — the author never appears:

Question: {"id":1,"content":"...","upvotes":2,"createdDate":"...","upvoted":true,"owned":false}
Answer:   {"id":1,"content":"...","createdDate":"...","owned":true}

upvoted and owned are computed per caller (not persisted) so the frontend can
render the upvote toggle and show edit/delete controls only on the caller's own
posts. Both are false for anonymous readers.

Status codes: 400 blank content · 403 not the author / unverified / no token ·
404 not found · 409 upvote state conflict · 429 cooldown.

Design decisions

Anonymity — askedBy / answeredBy are @JsonIgnored, so the author is
persisted for permission checks but never serialized.

One upvote per user — a question_upvotes table with a unique constraint on
(question_id, user_id). The constraint is the actual guard; the service check in
front of it only exists to return a friendlier error. The counter on questions
is updated with SET upvotes = upvotes + 1 in a single statement so concurrent
upvotes are not lost.

Spam cooldowns — 15 minutes between questions, 2 minutes between answers.
The slot is claimed with one conditional UPDATE on the user row rather than a
read-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 verified is 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: update handles this, no migration needed:

  • new tables questions, answers, question_upvotes
  • two new columns on the existing users table: last_question_date,
    last_answer_date (both @JsonIgnored, confirmed absent from every existing
    user-returning endpoint)

Testing

Tested locally against a Dockerised Postgres:

Follow-ups, deliberately not in this PR

  • No moderation path. Only the author can delete, so Admins cannot remove
    abusive anonymous posts. Worth adding given the feature is anonymous.
  • No automated tests. The project has skipTests=true and a single test class,
    so this PR matches that. Happy to add @SpringBootTest coverage of the ownership
    and cooldown logic if preferred.
  • offset behaves as a page index rather than a row offset, and pageSize is
    unbounded — both match the existing EventsController / UserController, so
    this PR stays consistent rather than diverging.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 UPDATE queries on the users table.

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 question association: the user link should be LAZY and 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 <= 0 but 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.

Comment on lines +119 to +123
throw new AcmException(ErrorConstants.QUESTION_NOT_UPVOTED, HttpStatus.CONFLICT);
}

questionUpvoteRepository.deleteByQuestionIdAndUserId(questionId, user.getId());
questionRepository.decrementUpvotes(questionId);
Comment on lines +29 to +32
@ManyToOne
@JoinColumn(name = "question_id")
@JsonIgnore
private Question question;
Comment on lines +33 to +36
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
@JsonIgnore
private User askedBy;
Comment on lines +28 to +31
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id")
@JsonIgnore
private Question question;
Comment on lines +34 to +37
@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);

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • markOwned performs userRepository.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 if email is 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

  • markCallerState performs userRepository.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 if email is 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 DataIntegrityViolationException and always returning QUESTION_ALREADY_UPVOTED can 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 computing owned/upvoted. Consider returning null for 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();
    }

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Comment on lines +80 to +86
public void deleteQuestion(Integer questionId, String email) {
Question question = getQuestion(questionId);
verifyAskedBy(question, email);

questionUpvoteRepository.deleteAllByQuestion(question);
answerRepository.deleteAllByQuestion(question);
questionRepository.delete(question);

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"fifteen minutes" sounds a little too low to me?
maybe bump it up to an hour?

import java.util.List;

@RestController
@RequestMapping("/v1")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we drop versioned api routes?
since we don't publish major api changes, feels redundant to me to keep it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +28 to +29
@Column(name = "upvotes", nullable = false)
private Integer upvotes = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this column feels redundant as we can always compute the number of updates from upvote details

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +39 to +41
@CreationTimestamp
@Column(name = "created_date")
private LocalDateTime createdDate;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same nitpick as the other comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, same rename applied here.

Comment on lines +38 to +40
@CreationTimestamp
@Column(name = "created_date")
private LocalDateTime createdDate;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a nitpick:

created_at feels better if we are storing full timestamps instead of just the date.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed on all three entities : questions, answers and upvotes. Payloads now return createdAt.

Comment on lines +58 to +66
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure how I feel about this being properties on the User object

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@PECACM PECACM left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good to me! 👌

@PECACM
PECACM merged commit d876197 into PEC-CSS:main Aug 23, 2026
1 check failed

This branch had an error being deployed

1 failed deployment
dockerhub — 40fb4dc0 Deployed Aug 23, 2026 by Kanavpreet-Singh via CI #96
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants