Skip to content

Commit c9ecc43

Browse files
audigregoriecursoragentasithadeclaude
authored
feat(votes): add proxy endpoint for submitting vote responses (#657)
* feat(votes): add proxy endpoint for submitting vote responses (LFXV2-1585) Adds POST /votes/responses on the SSR server that proxies a ballot submission to the upstream POST /vote_responses using the user's bearer token, so the response is attributed to the actual voter (no M2M). After the write, polls the query service until the indexed vote_response reflects 'submitted' status, mirroring the fetchPendingVotes query shape so the next pending-actions read is consistent with the write. Promotes the inline VoteResponseRow type from user.service.ts to a shared IndexedVoteResponse interface in @lfx-one/shared, and adds shared CreateVoteResponseRequest / VoteAnswerInput / RankedChoiceInput request types aligned with the upstream voting service contract. Disambiguates UID validation errors via an optional fieldName on validateUidParameter (defaults to 'uid' to preserve existing call sites), and reorders the literal-segment POST /responses route above the /:uid handlers as a forward-compat safety net for any future POST /:uid route. Signed-off-by: Audi Young <audi.mycloud@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(votes): reject vote content when abstaining (LFXV2-1585) Signed-off-by: Audi Young <audi.mycloud@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(votes): guard validateUidParameter against non-string input (LFXV2-1585) Signed-off-by: Audi Young <audi.mycloud@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(votes): reject any user_vote_content presence when abstaining (LFXV2-1585) Signed-off-by: Audi Young <audi.mycloud@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(votes): normalize abstain payload and validate answer shape (LFXV2-1585) Signed-off-by: Audi Young <audi.mycloud@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(votes): use validateRequiredParameter for body fields (LFXV2-1585) Signed-off-by: Audi Young <audi.mycloud@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(votes): address PR review feedback (LFXV2-1585) Extract validationContext, guard against null answer elements, add X-Sync header to vote response POST, and add INFO log for ballot acceptance. Signed-off-by: Audi Young <audi.mycloud@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> * style(votes): collapse ServiceValidationError calls to match printWidth=160 (LFXV2-1585) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Audi Young <audi.mycloud@gmail.com> * fix(votes): align createVoteResponse with codebase patterns (LFXV2-1585) - Demote post-POST INFO log to DEBUG to match createVote, enableVote, createSurvey, createMeeting which all rely on the controller's logger.success for the single INFO line per write request. - Build upstream payload immutably in createVoteResponse controller instead of mutating req.body.user_vote_content. - Include vote_id in pendingVoteUids fallback chain so v1-only indexer rows are not silently dropped. Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Audi Young <audi.mycloud@gmail.com> Signed-off-by: Asitha de Silva <asithade@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Asitha de Silva <asithade@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5ad8889 commit c9ecc43

6 files changed

Lines changed: 220 additions & 24 deletions

File tree

apps/lfx-one/src/server/controllers/vote.controller.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// Copyright The Linux Foundation and each contributor to LFX.
22
// SPDX-License-Identifier: MIT
33

4-
import { CreateVoteRequest, UpdateVoteRequest } from '@lfx-one/shared/interfaces';
4+
import { CreateVoteRequest, CreateVoteResponseRequest, UpdateVoteRequest } from '@lfx-one/shared/interfaces';
55
import { NextFunction, Request, Response } from 'express';
66

7-
import { validateUidParameter } from '../helpers/validation.helper';
7+
import { ServiceValidationError } from '../errors';
8+
import { validateRequestBody, validateRequiredParameter, validateUidParameter } from '../helpers/validation.helper';
89
import { logger } from '../services/logger.service';
910
import { VoteService } from '../services/vote.service';
1011

@@ -235,6 +236,90 @@ export class VoteController {
235236
}
236237
}
237238

239+
/**
240+
* POST /votes/responses
241+
*/
242+
public async createVoteResponse(req: Request, res: Response, next: NextFunction): Promise<void> {
243+
const payload: CreateVoteResponseRequest = req.body;
244+
const startTime = logger.startOperation(req, 'create_vote_response', {
245+
vote_uid: payload?.vote_uid,
246+
vote_response_uid: payload?.vote_response_uid,
247+
abstain: payload?.abstain,
248+
});
249+
250+
try {
251+
const validationContext = { operation: 'create_vote_response', service: 'vote_controller' } as const;
252+
253+
if (!validateRequestBody(payload, req, next, validationContext)) {
254+
return;
255+
}
256+
257+
if (!validateRequiredParameter(payload.vote_uid, 'vote_uid', req, next, validationContext)) {
258+
return;
259+
}
260+
261+
if (!validateRequiredParameter(payload.vote_response_uid, 'vote_response_uid', req, next, validationContext)) {
262+
return;
263+
}
264+
265+
if (typeof payload.abstain !== 'boolean') {
266+
throw ServiceValidationError.forField('abstain', 'abstain is required and must be a boolean', validationContext);
267+
}
268+
269+
// Build the upstream payload immutably: when abstaining we drop user_vote_content entirely;
270+
// when not abstaining we validate each answer and forward the original content.
271+
let upstreamPayload: CreateVoteResponseRequest;
272+
273+
if (payload.abstain) {
274+
upstreamPayload = {
275+
vote_uid: payload.vote_uid,
276+
vote_response_uid: payload.vote_response_uid,
277+
abstain: true,
278+
};
279+
} else {
280+
if (!Array.isArray(payload.user_vote_content) || payload.user_vote_content.length === 0) {
281+
throw ServiceValidationError.forField('user_vote_content', 'user_vote_content is required when not abstaining', validationContext);
282+
}
283+
284+
for (const [index, answer] of payload.user_vote_content.entries()) {
285+
if (!answer || typeof answer !== 'object') {
286+
throw ServiceValidationError.forField(`user_vote_content[${index}]`, 'Each answer must be a non-null object', validationContext);
287+
}
288+
289+
if (!answer.question_id || typeof answer.question_id !== 'string') {
290+
throw ServiceValidationError.forField(`user_vote_content[${index}].question_id`, 'question_id is required for each answer', validationContext);
291+
}
292+
293+
const hasChoiceIds = Array.isArray(answer.choice_ids) && answer.choice_ids.length > 0;
294+
const hasRankedChoices = Array.isArray(answer.ranked_choices) && answer.ranked_choices.length > 0;
295+
296+
if (!hasChoiceIds && !hasRankedChoices) {
297+
throw ServiceValidationError.forField(
298+
`user_vote_content[${index}]`,
299+
'Each answer must include either choice_ids or ranked_choices',
300+
validationContext
301+
);
302+
}
303+
}
304+
305+
upstreamPayload = payload;
306+
}
307+
308+
await this.voteService.createVoteResponse(req, upstreamPayload);
309+
310+
logger.success(req, 'create_vote_response', startTime, {
311+
vote_uid: upstreamPayload.vote_uid,
312+
vote_response_uid: upstreamPayload.vote_response_uid,
313+
abstain: upstreamPayload.abstain,
314+
status_code: 204,
315+
});
316+
317+
res.status(204).send();
318+
} catch (error) {
319+
next(error);
320+
}
321+
}
322+
238323
/**
239324
* PUT /votes/:uid/enable
240325
*/

apps/lfx-one/src/server/helpers/validation.helper.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,11 @@ interface ValidationOptions {
2424
}
2525

2626
/**
27-
* Validates that a UID parameter exists and is not empty
28-
* @param uid The UID value to validate
29-
* @param req Express request object
30-
* @param next Express next function for error handling
31-
* @param options Validation options including operation name
32-
* @returns true if validation passes, false if validation fails (error sent to next)
27+
* Validates that a UID route parameter exists and is not empty.
28+
* For validating named body/query fields, use validateRequiredParameter instead.
3329
*/
34-
export function validateUidParameter(uid: string | undefined, req: Request, next: NextFunction, options: ValidationOptions): uid is string {
35-
if (!uid || uid.trim() === '') {
30+
export function validateUidParameter(uid: unknown, req: Request, next: NextFunction, options: ValidationOptions): uid is string {
31+
if (typeof uid !== 'string' || uid.trim() === '') {
3632
const validationError = ServiceValidationError.forField('uid', 'UID is required', {
3733
operation: options.operation,
3834
service: options.service || 'controller',

apps/lfx-one/src/server/routes/votes.route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ router.get('/count', (req, res, next) => voteController.getVotesCount(req, res,
1818
// GET /votes/my-votes - get votes the current user has been invited to
1919
router.get('/my-votes', (req, res, next) => voteController.getMyVotes(req, res, next));
2020

21+
// POST /votes/responses - submit a vote response (ballot)
22+
// Note: literal-segment routes must precede '/:uid' handlers so Express
23+
// doesn't accidentally match 'responses' as a uid for any future POST /:uid/* route.
24+
router.post('/responses', (req, res, next) => voteController.createVoteResponse(req, res, next));
25+
2126
// GET /votes/:uid/results - get vote results
2227
router.get('/:uid/results', (req, res, next) => voteController.getVoteResults(req, res, next));
2328

apps/lfx-one/src/server/services/user.service.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ActiveWeeksStreakResponse,
1515
ActiveWeeksStreakRow,
1616
ApiGatewayUserProfile,
17+
IndexedVoteResponse,
1718
Meeting,
1819
MeetingOccurrence,
1920
MeetingRegistrant,
@@ -1088,21 +1089,12 @@ export class UserService {
10881089
* `end_time`, and `status` — everything the `transformVotesToActions` consumer needs.
10891090
*/
10901091
private async fetchPendingVotes(req: Request, projectUid?: string): Promise<Vote[]> {
1091-
interface VoteResponseRow {
1092-
vote_uid?: string;
1093-
vote_id?: string;
1094-
poll_id?: string;
1095-
project_uid?: string;
1096-
vote_status?: string;
1097-
voter_removed?: boolean;
1098-
}
1099-
11001092
// failOnPartial: completeness matters — a truncated response can silently miss a pending
11011093
// invitation. The caller already catches and degrades, so fail closed here.
1102-
const responses = await fetchAllQueryResources<VoteResponseRow>(
1094+
const responses = await fetchAllQueryResources<IndexedVoteResponse>(
11031095
req,
11041096
(pageToken) =>
1105-
this.microserviceProxy.proxyRequest<QueryServiceResponse<VoteResponseRow>>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', {
1097+
this.microserviceProxy.proxyRequest<QueryServiceResponse<IndexedVoteResponse>>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', {
11061098
type: 'vote_response',
11071099
filter_grants: 'direct',
11081100
...(projectUid && { filters: [`project_uid:${projectUid}`] }),
@@ -1111,13 +1103,13 @@ export class UserService {
11111103
{ failOnPartial: true }
11121104
);
11131105

1114-
// `vote_uid` is the v2 parent poll UID (what `/votes/{uid}` expects); `poll_id` is the v1
1115-
// fallback per the upstream indexer contract. Neither is the individual-response id.
1106+
// `vote_uid` is the v2 parent poll UID (what `/votes/{uid}` expects); `vote_id` and `poll_id`
1107+
// are v1 fallbacks per the upstream indexer contract. None of these is the individual-response id.
11161108
const pendingVoteUids = Array.from(
11171109
new Set(
11181110
responses
11191111
.filter((r) => r.vote_status !== 'submitted' && !r.voter_removed)
1120-
.map((r) => r.vote_uid ?? r.poll_id)
1112+
.map((r) => r.vote_uid ?? r.vote_id ?? r.poll_id)
11211113
.filter((uid): uid is string => !!uid)
11221114
)
11231115
);

apps/lfx-one/src/server/services/vote.service.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
import {
55
CreateVoteRequest,
6+
CreateVoteResponseRequest,
67
IndexedVote,
8+
IndexedVoteResponse,
79
QueryServiceCountResponse,
810
QueryServiceResponse,
911
UpdateVoteRequest,
@@ -246,6 +248,57 @@ export class VoteService {
246248
return results;
247249
}
248250

251+
/**
252+
* Submits a ballot via POST /vote_responses using the user's bearer token (no M2M),
253+
* then polls until the query service indexes the response as 'submitted'.
254+
*/
255+
public async createVoteResponse(req: Request, payload: CreateVoteResponseRequest): Promise<void> {
256+
logger.debug(req, 'create_vote_response', 'Submitting vote response', {
257+
vote_uid: payload.vote_uid,
258+
vote_response_uid: payload.vote_response_uid,
259+
abstain: payload.abstain,
260+
answer_count: payload.user_vote_content?.length ?? 0,
261+
});
262+
263+
await this.microserviceProxy.proxyRequest<void>(req, 'LFX_V2_SERVICE', '/vote_responses', 'POST', undefined, payload, {
264+
['X-Sync']: 'true',
265+
});
266+
267+
logger.debug(req, 'create_vote_response', 'Ballot accepted by upstream voting service, polling query service', {
268+
vote_uid: payload.vote_uid,
269+
vote_response_uid: payload.vote_response_uid,
270+
});
271+
272+
const resolved = await pollEndpoint({
273+
req,
274+
operation: 'create_vote_response_poll',
275+
pollFn: async () => {
276+
const { resources } = await this.microserviceProxy.proxyRequest<QueryServiceResponse<IndexedVoteResponse>>(
277+
req,
278+
'LFX_V2_SERVICE',
279+
'/query/resources',
280+
'GET',
281+
{
282+
type: 'vote_response',
283+
filter_grants: 'direct',
284+
filters: [`vote_uid:${payload.vote_uid}`],
285+
}
286+
);
287+
return resources.some((r) => r.data.uid === payload.vote_response_uid && r.data.vote_status === 'submitted');
288+
},
289+
maxRetries: 5,
290+
retryDelayMs: 1000,
291+
metadata: { vote_uid: payload.vote_uid, vote_response_uid: payload.vote_response_uid },
292+
});
293+
294+
if (!resolved) {
295+
logger.warning(req, 'create_vote_response', 'Vote response not yet indexed, client may see stale state', {
296+
vote_uid: payload.vote_uid,
297+
vote_response_uid: payload.vote_response_uid,
298+
});
299+
}
300+
}
301+
249302
// ============================================
250303
// My Votes (Me Lens)
251304
// ============================================

packages/shared/src/interfaces/poll.interface.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,29 @@ export interface IndexedVote extends Omit<Vote, 'uid'> {
227227
uid?: string;
228228
}
229229

230+
/**
231+
* Vote response row from the query service indexer (`lfx.index.vote_response`).
232+
* Tagged by `vote_uid` (not `vote_response_uid`); use `filter_grants=direct` to
233+
* scope to the current user's rows. `vote_status` is `string` — the indexer's
234+
* vocabulary is broader than the UI enums.
235+
*/
236+
export interface IndexedVoteResponse {
237+
/** Vote response (ballot) identifier — the row's own primary key */
238+
uid?: string;
239+
/** Parent poll identifier (v2). Indexer tag key. */
240+
vote_uid?: string;
241+
/** V1 fallback for the parent poll identifier */
242+
vote_id?: string;
243+
/** V1 alias for `vote_id` carried by older indexer versions */
244+
poll_id?: string;
245+
/** V2 project UID the response belongs to */
246+
project_uid?: string;
247+
/** Upstream submission status (e.g. `'submitted'`, `'awaiting_response'`) */
248+
vote_status?: string;
249+
/** Whether the voter has been removed from the poll's eligible list */
250+
voter_removed?: boolean;
251+
}
252+
230253
/**
231254
* Individual Vote entity from query service
232255
* @description Represents a user's participation record from lfx.index.individual_vote
@@ -563,6 +586,48 @@ export interface CreateVoteRequest {
563586
winning_threshold_percentage?: number;
564587
}
565588

589+
/**
590+
* Ranked choice submission for a single choice in a ranked-choice question
591+
* @description Aligns with upstream RankedChoiceInput
592+
* @see https://github.com/linuxfoundation/lfx-v2-voting-service
593+
*/
594+
export interface RankedChoiceInput {
595+
/** Choice identifier (UUID) */
596+
choice_id: string;
597+
/** 1-based rank assigned to the choice */
598+
choice_rank: number;
599+
}
600+
601+
/**
602+
* Voter's answer to a single question on a ballot submission
603+
* @description Aligns with upstream VoteAnswerInput
604+
* @see https://github.com/linuxfoundation/lfx-v2-voting-service
605+
*/
606+
export interface VoteAnswerInput {
607+
/** Question identifier (UUID) */
608+
question_id: string;
609+
/** Selected choice IDs for generic / single-choice / multi-choice questions */
610+
choice_ids?: string[];
611+
/** Ranked choices for ranked-choice voting questions */
612+
ranked_choices?: RankedChoiceInput[];
613+
}
614+
615+
/**
616+
* Request body for submitting a vote response (ballot)
617+
* @description Aligns with upstream POST /vote_responses
618+
* @see https://github.com/linuxfoundation/lfx-v2-voting-service
619+
*/
620+
export interface CreateVoteResponseRequest {
621+
/** Client-generated vote response identifier (UUID) */
622+
vote_response_uid: string;
623+
/** Vote/poll identifier this response belongs to (UUID) */
624+
vote_uid: string;
625+
/** Whether the voter is abstaining */
626+
abstain: boolean;
627+
/** Voter's answers — required when not abstaining */
628+
user_vote_content?: VoteAnswerInput[];
629+
}
630+
566631
/**
567632
* Request body for updating a vote/poll
568633
* @description Only permitted when vote status is "disabled"

0 commit comments

Comments
 (0)