Basic upload and document insert working for imgproxy.

This commit is contained in:
Patrick Fic
2025-10-14 13:10:32 -07:00
parent 9bab7080e6
commit 7fe1ea65f2
4 changed files with 90 additions and 72 deletions

View File

@@ -19,3 +19,8 @@ export const mediaUploadFailure = (error) => ({
type: PhotosActionTypes.MEDIA_UPLOAD_FAILURE,
payload: error,
});
export const mediaUploadSuccessOne = (photo) => ({
type: PhotosActionTypes.MEDIA_UPLOAD_SUCCESS_ONE,
payload: photo,
});

View File

@@ -30,6 +30,11 @@ const photosReducer = (state = INITIAL_STATE, action) => {
...state,
progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], ...action.payload } }
};
case PhotosActionTypes.MEDIA_UPLOAD_SUCCESS_ONE:
return {
...state,
progress: { ...state.progress, [action.payload.assetId]: { ...state.progress[action.payload.assetId], progress: 100, status: 'completed' } }
};
default:
return state;

View File

@@ -1,18 +1,21 @@
import axios from "axios";
import Constants from "expo-constants";
import * as ImagePicker from "expo-image-picker";
import { END, eventChannel } from 'redux-saga';
import { all, call, delay, fork, put, select, take, takeEvery, takeLatest } from "redux-saga/effects";
import moment from 'moment';
import { all, call, delay, fork, put, select, takeEvery, takeLatest } from "redux-saga/effects";
import env from "../../env";
import { client } from '../../graphql/client';
import { INSERT_NEW_DOCUMENT } from "../../graphql/documents.queries";
import { axiosAuthInterceptorId } from "../../util/CleanAxios";
import { fetchArrayBufferFromUri, fetchImageFromUri, replaceAccents } from '../../util/uploadUtils';
import { selectBodyshop } from "../user/user.selectors";
import { fetchImageFromUri, replaceAccents } from '../../util/uploadUtils';
import { selectBodyshop, selectCurrentUser } from "../user/user.selectors";
import {
mediaUploadComplete,
mediaUploadFailure,
mediaUploadProgressOne,
mediaUploadStart
mediaUploadStart,
mediaUploadSuccessOne
} from "./photos.actions";
import PhotosActionTypes from "./photos.types";
//Required to prevent headers from getting set and rejected from Cloudinary.
@@ -44,6 +47,7 @@ export function* openImagePickerAction({ payload: jobid }) {
aspect: [4, 3],
quality: 1,
allowsMultipleSelection: true,
exif: true,
});
if (!(result.canceled)) {
yield put(mediaUploadStart({ photos: result.assets, jobid }));
@@ -80,10 +84,10 @@ export function* mediaUploadStartAction({ payload: { photos, jobid } }) {
// Small delay between batches to prevent overwhelming the server
yield delay(100);
}
yield put(mediaUploadComplete());
//yield put(mediaUploadSuccess(photo));
} catch (error) {
console.log("Saga Error: upload start", error);
console.log("Saga Error: upload start", error, error.stack);
yield put(mediaUploadFailure(error.message));
}
}
@@ -93,8 +97,6 @@ function* uploadSinglePhoto(photo, bodyshop, index, jobid) {
yield put(mediaUploadProgressOne({ ...photo, status: 'starting', progress: 0 }));
const photoBlob = yield fetchImageFromUri(photo.uri); //has a name, and type.
const photoArrayBuffer = yield fetchArrayBufferFromUri(photo.uri);
const extension = photoBlob.data?.name?.split('.').pop();
const key = `${bodyshop.id}/${jobid}/${replaceAccents(
@@ -102,15 +104,15 @@ function* uploadSinglePhoto(photo, bodyshop, index, jobid) {
).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.${extension}`
if (bodyshop.uselocalmediaserver) {
yield call(uploadToLocalMediaServer, photo, photoBlob, photoArrayBuffer, key, bodyshop, jobid);
yield call(uploadToLocalMediaServer, photo, photoBlob, extension, key, bodyshop, jobid);
} else {
yield call(uploadToImageProxy, photo, photoBlob, photoArrayBuffer, key, bodyshop, jobid);
yield call(uploadToImageProxy, photo, photoBlob, extension, key, bodyshop, jobid);
}
// yield put(mediaUploadSuccess({ photoId, photo }));
yield put(mediaUploadSuccessOne(photo));
} catch (error) {
console.log(`Upload failed for photo ${photo.assetId}:`, error);
console.log(`Upload failed for photo ${photo.uri}:`, error);
yield put(mediaUploadFailure({ ...photo, status: "error", error: error.message }));
}
}
@@ -152,7 +154,7 @@ function* uploadToLocalMediaServer(photo, key) {
}
}
function* uploadToImageProxy(photo, photoBlob, photoArrayBuffer, key, bodyshop, jobid) {
function* uploadToImageProxy(photo, photoBlob, extension, key, bodyshop, jobid) {
try {
yield put(mediaUploadProgressOne({ ...photo, status: 'uploading', }));
//Get the signed url allowing us to PUT to S3.
@@ -172,31 +174,69 @@ function* uploadToImageProxy(photo, photoBlob, photoArrayBuffer, key, bodyshop,
const { presignedUrl: preSignedUploadUrlToS3, key: s3Key } =
signedURLResponse.data.signedUrls[0];
// Create an eventChannel to bridge imperative upload progress events into the saga world
const uploadChannel = yield call(createAxiosUploadChannel, {
url: preSignedUploadUrlToS3,
data: photoArrayBuffer,
method: 'put',
headers: {
'Content-Type': photoBlob.type,
'Content-Length': photoBlob.size,
},
});
let uploadResult
try {
while (true) {
const { progress, error, success } = yield take(uploadChannel);
if (progress != null) {
yield put(mediaUploadProgressOne({ ...photo, progress }));
} else if (error) {
throw error;
} else if (success) {
yield put(mediaUploadProgressOne({ ...photo, progress: 100, status: 'completed' }));
break;
}
}
} finally {
uploadChannel.close();
uploadResult = yield new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", preSignedUploadUrlToS3);
xhr.setRequestHeader("Content-Type", photoBlob.type);
xhr.upload.onprogress = (e) => {
console.log("*** ~ awaitnewPromise ~ event:", e);
if (e.lengthComputable) {
console.log(`Upload progress for ${photo.uri}:`, e.loaded / e.total);
put(mediaUploadProgressOne({ ...photo, progress: e.loaded / e.total }));
}
};
xhr.onload = () => {
if (xhr.status === 200) {
resolve(true);
} else {
reject(new Error(`Upload failed: ${xhr.statusText}`));
}
};
xhr.onerror = (req, event) => {
reject(new Error("Network error"));
};
xhr.send(photoBlob);
});
} catch (error) {
console.log("Error uploading to S3", error.message, error.stack);
throw error;
}
if (uploadResult) {
//Create doc record.
const uploaded_by = yield select(selectCurrentUser);
const [date, time] = photo.exif?.DateTime?.split(' ') || [];
const [year, month, day] = date ? date.split(':') : [];
const [hours, minutes, seconds] = time ? time.split(':') : [];
const pictureMoment = moment(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}`);
const documentInsert = yield call(client.mutate, ({
mutation: INSERT_NEW_DOCUMENT,
variables: {
docInput: [
{
...(jobid ? { jobid: jobid } : {}),
uploaded_by: uploaded_by.email,
key: s3Key,
type: photoBlob.type,
extension: extension,
bodyshopid: bodyshop.id,
size: photoBlob.size,
...(photo.exif?.DateTime //TODO :Need to find how to do this.
? { takenat: pictureMoment }
: {}),
},
],
},
}));
console.log("*** ~ uploadToImageProxy ~ documentInsert:", JSON.stringify(documentInsert, null, 2));
}
} catch (error) {
@@ -205,38 +245,6 @@ function* uploadToImageProxy(photo, photoBlob, photoArrayBuffer, key, bodyshop,
}
}
// Helper: turn axios upload progress into an eventChannel so we can yield put from saga
function createAxiosUploadChannel({ url, data, method = 'put', headers }) {
return eventChannel(emitter => {
const controller = new AbortController();
cleanAxios({
method,
url,
data,
headers,
signal: controller.signal,
// NOTE: cannot yield here, so we emit events and handle them in saga loop
onUploadProgress: (e) => {
console.log("Upload progress event:", e);
if (e && e.total) {
emitter({ progress: e.loaded / e.total });
}
}
}).then(() => {
emitter({ success: true });
emitter(END);
}).catch(err => {
emitter({ error: err });
emitter(END);
});
// Unsubscribe / cancellation logic
return () => {
controller.abort()
};
});
}
// Handle cancellation of uploads
export function* onCancelUpload() {
yield takeEvery(PhotosActionTypes.CANCEL_UPLOAD, cancelUploadAction);

View File

@@ -1,7 +1,7 @@
const PhotosActionTypes = {
OPEN_IMAGE_PICKER: "OPEN_IMAGE_PICKER",
MEDIA_UPLOAD_START: "MEDIA_UPLOAD_START",
MEDIUA_UPLOAD_SUCCESS: "MEDIA_UPLOAD_SUCCESS",
MEDIA_UPLOAD_SUCCESS_ONE: "MEDIA_UPLOAD_SUCCESS_ONE",
MEDIA_UPLOAD_FAILURE: "MEDIA_UPLOAD_FAILURE",
MEDIA_UPLOAD_PROGRESS_UPDATE_ONE: "MEDIA_UPLOAD_PROGRESS_UPDATE_ONE",
};