Skip to content
Merged
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
12 changes: 9 additions & 3 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Phase 1 of the Ranked Choices Expo migration. This app currently provides a
shortcode lookup, ballot preview, and local candidate-ranking controls backed
by the existing PHP API. It does not submit votes or authenticate users yet.
by the existing PHP API. Anonymous ballots can be submitted through the typed,
idempotent v2 vote endpoint. The app does not authenticate users yet.

## Get started

Expand Down Expand Up @@ -65,10 +66,15 @@ connectivity will be designed alongside the later web deployment decision.
- typed normalization of the legacy `get-candidates.php` response
- ballot lookup and accessible local candidate ranking
- move-up, move-down, remove, and reset controls
- idempotent anonymous vote submission with loading, retry, duplicate-device,
cutoff, and accepted states
- loading, closed, not-found, malformed-response, and network-error handling

Vote submission, authentication, production deployment, and domain association
files are intentionally deferred to later slices.
Name-required ballots, secure-code entry, grouping questions, authentication,
production deployment, and domain association files remain unavailable. The
first three belong to the later secure-voting/ballot-creation phase; this Phase
1 client surfaces them as explicit unsupported states instead of submitting an
incomplete vote.

## Expo resources

Expand Down
44 changes: 44 additions & 0 deletions apps/mobile/package-lock.json

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

2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
"main": "expo-router/entry",
"version": "1.0.0",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~57.0.11",
"expo-constants": "~57.0.9",
"expo-crypto": "~57.0.1",
"expo-linking": "~57.0.5",
"expo-router": "~57.0.11",
"expo-splash-screen": "~57.0.5",
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { getApiBaseUrl } from '@/config/api';

import { LegacyApiClient } from './legacy-api';
import { V2ApiClient } from './v2-api';

export function createLegacyApiClient(): LegacyApiClient {
return new LegacyApiClient({ baseUrl: getApiBaseUrl() });
}

export function createV2ApiClient(): V2ApiClient {
return new V2ApiClient({ baseUrl: getApiBaseUrl() });
}
96 changes: 96 additions & 0 deletions apps/mobile/src/api/v2-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest';

import { V2ApiClient, V2ApiError } from './v2-api';

const request = {
key: 'pizza',
requestId: '12345678-1234-4234-8234-123456789012',
ranking: [3, 1, 2],
fingerprint: 'installation-id',
};

describe('V2ApiClient.submitVote', () => {
it('submits typed rankings and returns the accepted response', async () => {
const fetchImpl = vi.fn(async () =>
new Response(
JSON.stringify({
data: { status: 'accepted', voteId: 42, replayed: false },
error: null,
}),
{ status: 201 },
),
);
const client = new V2ApiClient({ baseUrl: 'https://example.test/api/', fetchImpl });

await expect(client.submitVote(request)).resolves.toEqual({
status: 'accepted',
voteId: 42,
replayed: false,
});
expect(fetchImpl).toHaveBeenCalledWith('https://example.test/api/v2/votes.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal: undefined,
});
});

it('preserves typed duplicate errors', async () => {
const client = new V2ApiClient({
baseUrl: 'https://example.test/api',
fetchImpl: async () =>
new Response(
JSON.stringify({
data: null,
error: { code: 'duplicate_device', message: 'Already voted.' },
}),
{ status: 409 },
),
});

await expect(client.submitVote(request)).rejects.toMatchObject({
code: 'duplicate_device',
retryable: false,
status: 409,
});
});

it('marks transport and server errors as retryable', async () => {
const networkClient = new V2ApiClient({
baseUrl: 'https://example.test/api',
fetchImpl: async () => {
throw new Error('offline');
},
});
const serverClient = new V2ApiClient({
baseUrl: 'https://example.test/api',
fetchImpl: async () =>
new Response(
JSON.stringify({
data: null,
error: { code: 'server_error', message: 'Try later.' },
}),
{ status: 500 },
),
});

await expect(networkClient.submitVote(request)).rejects.toMatchObject({
code: 'network',
retryable: true,
});
await expect(serverClient.submitVote(request)).rejects.toMatchObject({
code: 'server_error',
retryable: true,
});
});

it('rejects malformed envelopes at the compatibility seam', async () => {
const client = new V2ApiClient({
baseUrl: 'https://example.test/api',
fetchImpl: async () => new Response(JSON.stringify({ ok: true })),
});

await expect(client.submitVote(request)).rejects.toBeInstanceOf(V2ApiError);
await expect(client.submitVote(request)).rejects.toMatchObject({ code: 'malformed_response' });
});
});
144 changes: 144 additions & 0 deletions apps/mobile/src/api/v2-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
export type SubmitVoteRequest = {
key: string;
requestId: string;
ranking: number[];
fingerprint?: string;
};

export type SubmitVoteResult = {
status: 'accepted';
voteId: number;
replayed: boolean;
};

export type V2ApiErrorCode =
| 'validation_failed'
| 'ballot_not_found'
| 'idempotency_conflict'
| 'voting_closed'
| 'voter_name_required'
| 'secure_code_required'
| 'group_answers_required'
| 'fingerprint_required'
| 'duplicate_device'
| 'invalid_ranking'
| 'server_error'
| 'network'
| 'http'
| 'malformed_response';

export class V2ApiError extends Error {
constructor(
public readonly code: V2ApiErrorCode,
message: string,
public readonly retryable = false,
public readonly status?: number,
) {
super(message);
this.name = 'V2ApiError';
}
}

type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;

type V2ApiClientOptions = {
baseUrl: string;
fetchImpl?: FetchLike;
};

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isKnownErrorCode(value: unknown): value is V2ApiErrorCode {
return (
typeof value === 'string' &&
[
'validation_failed',
'ballot_not_found',
'idempotency_conflict',
'voting_closed',
'voter_name_required',
'secure_code_required',
'group_answers_required',
'fingerprint_required',
'duplicate_device',
'invalid_ranking',
'server_error',
].includes(value)
);
}

export class V2ApiClient {
private readonly baseUrl: string;
private readonly fetchImpl: FetchLike;

constructor({ baseUrl, fetchImpl = fetch }: V2ApiClientOptions) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.fetchImpl = fetchImpl;
}

async submitVote(request: SubmitVoteRequest, signal?: AbortSignal): Promise<SubmitVoteResult> {
let response: Response;
try {
response = await this.fetchImpl(`${this.baseUrl}/v2/votes.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal,
});
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') throw error;
throw new V2ApiError('network', 'The vote server could not be reached.', true);
}

let envelope: unknown;
try {
envelope = JSON.parse(await response.text());
} catch {
if (!response.ok) {
throw new V2ApiError(
'http',
'The vote server returned an error.',
response.status >= 500,
response.status,
);
}
throw new V2ApiError('malformed_response', 'The vote server returned invalid data.');
}

if (!isRecord(envelope)) {
throw new V2ApiError('malformed_response', 'The vote server returned invalid data.');
}

if (envelope.error !== null) {
const error = envelope.error;
if (!isRecord(error) || !isKnownErrorCode(error.code) || typeof error.message !== 'string') {
throw new V2ApiError('malformed_response', 'The vote server returned invalid error data.');
}
throw new V2ApiError(
error.code,
error.message,
error.code === 'server_error' || response.status >= 500,
response.status,
);
}

const data = envelope.data;
if (
!response.ok ||
!isRecord(data) ||
data.status !== 'accepted' ||
typeof data.voteId !== 'number' ||
typeof data.replayed !== 'boolean'
) {
throw new V2ApiError('malformed_response', 'The vote server returned invalid success data.');
}

return {
status: 'accepted',
voteId: data.voteId,
replayed: data.replayed,
};
}
}
Loading
Loading