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
169 changes: 65 additions & 104 deletions api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,74 +359,12 @@ export const driverAPI = {
},
};

export type PartnerTuple = [number, string];

function isPartnerTuple(value: unknown): value is PartnerTuple {
return (
Array.isArray(value) &&
value.length === 2 &&
typeof value[0] === 'number' &&
typeof value[1] === 'string'
);
}

export async function getPartners(): Promise<PartnerTuple[]> {
const responseData = await apiRequest<unknown>(
`${BASE_URL}${API_ENDPOINTS.PARTNERS}`,
{
method: 'GET',
},
);

if (!Array.isArray(responseData)) {
throw new ApiUtilError('Invalid partners response shape from server', 0, [
'validation_failed',
]);
}

return responseData.filter(isPartnerTuple);
}

export type TaskResponse = {
id: number;
encrypted_id: string;
pickup_date: string;
start_time: string | null;
end_time: string | null;
location_name: string | null;
food_rescuer_notes?: string | null;
total_pounds_entered?: number | null;
export const getPartners = async () => {
return apiRequest(`${BASE_URL}${API_ENDPOINTS.PARTNERS}`, {
method: 'GET',
});
};

function isTaskResponse(value: unknown): value is TaskResponse {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;

const record = value as Record<string, unknown>;

return (
typeof record.id === 'number' &&
typeof record.encrypted_id === 'string' &&
typeof record.pickup_date === 'string' &&
(record.start_time === null || typeof record.start_time === 'string') &&
(record.end_time === null || typeof record.end_time === 'string') &&
(record.location_name === null || typeof record.location_name === 'string')
);
}

export async function getTask(encryptedTaskId: string): Promise<TaskResponse> {
const safeId = encodeURIComponent(encryptedTaskId);

return apiRequest<TaskResponse>(
`${BASE_URL}${API_ENDPOINTS.TASKS}/${safeId}`,
{
method: 'GET',
validateResponse: responseData => {
return isTaskResponse(responseData);
},
},
);
}

interface UpdateDriverResponse {
id: number;
email: string;
Expand Down Expand Up @@ -477,6 +415,18 @@ export async function updateDriverPartner(
}
}

export async function getTask(encryptedTaskId: string) {
const safeId = encodeURIComponent(encryptedTaskId);

return apiRequest(`${BASE_URL}${API_ENDPOINTS.TASKS}/${safeId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}

export async function claimTask(encryptedTaskId: string, driverId: number) {
const safeId = encodeURIComponent(encryptedTaskId);

Expand All @@ -490,54 +440,65 @@ export async function claimTask(encryptedTaskId: string, driverId: number) {
});
}

type DonationCompletionPayload = {
status?: 'complete' | 'failed' | 'cancelled_late';
total_pounds_entered: number;
food_rescuer_notes: string | null;
rescuer_logs: unknown[];
};

export type DonationCompletionStatus = 'complete' | 'failed' | 'cancelled_late';

export async function submitDonationCompletion(
encryptedTaskId: string,
payload: DonationCompletionPayload,
) {
export async function submitTaskMissed(encryptedTaskId: string) {
const safeId = encodeURIComponent(encryptedTaskId);

return apiRequest(`${BASE_URL}${API_ENDPOINTS.TASKS}/${safeId}`, {
return apiRequest(`${BASE_URL}/api/tasks/${safeId}/mark_as_failed`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
}

export async function submitTaskCompletion(
export async function submitCompletionDetails(
encryptedTaskId: string,
pounds: number,
notes: string | null,
rescuerLogs: unknown[] = [],
): Promise<unknown> {
return submitDonationCompletion(encryptedTaskId, {
status: 'complete',
total_pounds_entered: pounds,
food_rescuer_notes: notes,
rescuer_logs: rescuerLogs,
});
}
data: {
total_pounds_entered: string;
description?: string;
photoUri?: string | null;
},
) {
const safeId = encodeURIComponent(encryptedTaskId);
const url = `${BASE_URL}/api/tasks/${safeId}/update_completion_details`;

if (data.photoUri) {
// Multipart: can't send a file as JSON. Use FormData and let fetch
// set the Content-Type with the correct boundary automatically.
const form = new FormData();
form.append('task[total_pounds_entered]', data.total_pounds_entered);
if (data.description) {
form.append('task[description]', data.description);
}

export async function submitTaskMissed(
encryptedTaskId: string,
notes: string | null,
rescuerLogs: unknown[] = [],
): Promise<unknown> {
return submitDonationCompletion(encryptedTaskId, {
status: 'failed',
total_pounds_entered: 0,
food_rescuer_notes: notes,
rescuer_logs: rescuerLogs,
// Infer filename + mime from the URI (expo-image-picker gives file://...)
const filename = data.photoUri.split('/').pop() || 'photo.jpg';
const extMatch = /\.(\w+)$/.exec(filename);
const ext = extMatch ? extMatch[1].toLowerCase() : 'jpg';
const mimeType = ext === 'png' ? 'image/png' : 'image/jpeg';

// React Native's FormData accepts a {uri, name, type} object as a file
// value, but TypeScript's DOM types only know about Blob | string. The
// double-cast is the standard RN workaround. fetch on RN serializes this
// shape into a real multipart file part at runtime.
form.append('task[photo]', {
uri: data.photoUri,
name: filename,
type: mimeType,
} as unknown as Blob);

return apiRequest(url, {
method: 'PATCH',
body: form,
});
}

return apiRequest(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
total_pounds_entered: data.total_pounds_entered,
description: data.description,
}),
});
}
7 changes: 1 addition & 6 deletions src/app/(tabs)/available-pick-ups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,8 @@ export default function AvailablePickupsPage() {
const source = remote ?? [];
const filtered = React.useMemo(() => {
const base = source.filter(p => p.pickup_date === selectedISO);

// Only show mock card on TODAY
if (__DEV__ && selectedISO === todayISO) {
return [...base];
}
return base;
}, [source, selectedISO, todayISO]);
}, [source, selectedISO]);

// Show loading indicator on initial load
if (isLoading && !remote) {
Expand Down
Loading
Loading