feat(camera): add camera management functionality and data flow documentation
- Implemented camera slice in Redux store for managing camera state. - Added new tables in PostgreSQL for camera devices, lending, and audit logs. - Created camera-related schemas in contracts for validation. - Developed camera data flow documentation outlining ingest and read processes. - Introduced async thunks for fetching, creating, lending, returning, and updating cameras.
This commit is contained in:
1 parent
7923ba1b8d
commit
d3b6c5032f
11 files changed
+2524
-114
No files matched your search
@@ -0,0 +1,466 @@
|
||||
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||
import {
|
||||
cameraAuditLogListSchema,
|
||||
cameraCreateRequestSchema,
|
||||
cameraDeviceUpdateSchema,
|
||||
cameraListResponseSchema,
|
||||
cameraReturnResponseSchema,
|
||||
cameraSimulateIngestResponseSchema,
|
||||
type CameraAuditLog,
|
||||
type CameraListItem,
|
||||
} from 'contracts'
|
||||
import { ingestApi } from '../api/client'
|
||||
import { toErrorMessage } from './greeting-slice'
|
||||
import type { RootState } from './index'
|
||||
|
||||
export type CameraFormState = {
|
||||
borrower: string
|
||||
purpose: string
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
export type CameraEditFormState = {
|
||||
name: string
|
||||
location: string
|
||||
model: string
|
||||
notes: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type CameraPageState = {
|
||||
cameras: CameraListItem[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
message: string | null
|
||||
isCreating: boolean
|
||||
simulatingCameraId: string | null
|
||||
createModalOpen: boolean
|
||||
lendModalCameraId: string | null
|
||||
editModalCameraId: string | null
|
||||
auditModalCameraId: string | null
|
||||
auditLogs: CameraAuditLog[]
|
||||
isAuditLoading: boolean
|
||||
isLending: boolean
|
||||
isUpdating: boolean
|
||||
isReturning: string | null
|
||||
isTogglingActivation: string | null
|
||||
lendForm: CameraFormState
|
||||
createForm: CameraEditFormState & { cameraId: string }
|
||||
editForm: CameraEditFormState
|
||||
}
|
||||
|
||||
const initialState: CameraPageState = {
|
||||
cameras: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
message: null,
|
||||
isCreating: false,
|
||||
simulatingCameraId: null,
|
||||
createModalOpen: false,
|
||||
lendModalCameraId: null,
|
||||
editModalCameraId: null,
|
||||
auditModalCameraId: null,
|
||||
auditLogs: [],
|
||||
isAuditLoading: false,
|
||||
isLending: false,
|
||||
isUpdating: false,
|
||||
isReturning: null,
|
||||
isTogglingActivation: null,
|
||||
lendForm: {
|
||||
borrower: '',
|
||||
purpose: '',
|
||||
dueDate: '',
|
||||
},
|
||||
createForm: {
|
||||
cameraId: '',
|
||||
name: '',
|
||||
location: '',
|
||||
model: '',
|
||||
notes: '',
|
||||
isActive: true,
|
||||
},
|
||||
editForm: {
|
||||
name: '',
|
||||
location: '',
|
||||
model: '',
|
||||
notes: '',
|
||||
isActive: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const fetchCameras = createAsyncThunk<CameraListItem[], void, { rejectValue: string }>(
|
||||
'cameraPage/fetchCameras',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.get('/cameras')
|
||||
const parsed = cameraListResponseSchema.parse(response.data)
|
||||
return parsed.items
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const createCamera = createAsyncThunk<string, void, { state: RootState; rejectValue: string }>(
|
||||
'cameraPage/createCamera',
|
||||
async (_, { dispatch, getState, rejectWithValue }) => {
|
||||
const { createForm } = getState().cameraPage
|
||||
try {
|
||||
const body = cameraCreateRequestSchema.parse({
|
||||
cameraId: createForm.cameraId,
|
||||
name: createForm.name,
|
||||
location: createForm.location,
|
||||
model: createForm.model ? createForm.model : null,
|
||||
notes: createForm.notes ? createForm.notes : null,
|
||||
isActive: createForm.isActive,
|
||||
})
|
||||
|
||||
await ingestApi.post('/cameras', body)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return body.cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const simulateCameraIngest = createAsyncThunk<string, string, { rejectValue: string }>(
|
||||
'cameraPage/simulateIngest',
|
||||
async (cameraId, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.post(`/cameras/${cameraId}/simulate-ingest`, {})
|
||||
cameraSimulateIngestResponseSchema.parse(response.data)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const submitCameraLend = createAsyncThunk<string, string, { state: RootState; rejectValue: string }>(
|
||||
'cameraPage/submitLend',
|
||||
async (cameraId, { dispatch, getState, rejectWithValue }) => {
|
||||
const { lendForm } = getState().cameraPage
|
||||
if (!lendForm.borrower || !lendForm.dueDate) {
|
||||
return rejectWithValue('Borrower and due date are required')
|
||||
}
|
||||
|
||||
try {
|
||||
await ingestApi.post(`/cameras/${cameraId}/lend`, {
|
||||
borrower: lendForm.borrower,
|
||||
dueDate: new Date(lendForm.dueDate).toISOString(),
|
||||
purpose: lendForm.purpose || undefined,
|
||||
})
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const returnCamera = createAsyncThunk<string, string, { rejectValue: string }>(
|
||||
'cameraPage/returnCamera',
|
||||
async (cameraId, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.post(`/cameras/${cameraId}/return`, {})
|
||||
cameraReturnResponseSchema.parse(response.data)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const saveCameraUpdates = createAsyncThunk<
|
||||
string,
|
||||
string,
|
||||
{ state: RootState; rejectValue: string }
|
||||
>('cameraPage/saveUpdates', async (cameraId, { dispatch, getState, rejectWithValue }) => {
|
||||
const { editForm } = getState().cameraPage
|
||||
|
||||
try {
|
||||
const patch = cameraDeviceUpdateSchema.parse({
|
||||
name: editForm.name,
|
||||
location: editForm.location,
|
||||
model: editForm.model ? editForm.model : null,
|
||||
notes: editForm.notes ? editForm.notes : null,
|
||||
isActive: editForm.isActive,
|
||||
})
|
||||
await ingestApi.patch(`/cameras/${cameraId}`, patch)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
})
|
||||
|
||||
export const toggleCameraActivation = createAsyncThunk<
|
||||
{ cameraId: string; nextActive: boolean },
|
||||
{ cameraId: string; isActive: boolean },
|
||||
{ rejectValue: string }
|
||||
>('cameraPage/toggleActivation', async ({ cameraId, isActive }, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const nextActive = !isActive
|
||||
await ingestApi.patch(`/cameras/${cameraId}`, { isActive: nextActive })
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return { cameraId, nextActive }
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
})
|
||||
|
||||
export const fetchCameraAudit = createAsyncThunk<
|
||||
{ cameraId: string; items: CameraAuditLog[] },
|
||||
string,
|
||||
{ rejectValue: string }
|
||||
>('cameraPage/fetchAudit', async (cameraId, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.get(`/cameras/${cameraId}/audit`, { params: { limit: 50 } })
|
||||
const parsed = cameraAuditLogListSchema.parse(response.data)
|
||||
return { cameraId, items: parsed.items }
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
})
|
||||
|
||||
const cameraPageSlice = createSlice({
|
||||
name: 'cameraPage',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearBanner(state) {
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
openLendModal(state, action: PayloadAction<string>) {
|
||||
state.lendModalCameraId = action.payload
|
||||
state.lendForm = { borrower: '', purpose: '', dueDate: '' }
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
openCreateModal(state) {
|
||||
state.createModalOpen = true
|
||||
state.createForm = {
|
||||
cameraId: '',
|
||||
name: '',
|
||||
location: '',
|
||||
model: '',
|
||||
notes: '',
|
||||
isActive: true,
|
||||
}
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
closeCreateModal(state) {
|
||||
state.createModalOpen = false
|
||||
},
|
||||
setCreateCameraId(state, action: PayloadAction<string>) {
|
||||
state.createForm.cameraId = action.payload
|
||||
},
|
||||
setCreateName(state, action: PayloadAction<string>) {
|
||||
state.createForm.name = action.payload
|
||||
},
|
||||
setCreateLocation(state, action: PayloadAction<string>) {
|
||||
state.createForm.location = action.payload
|
||||
},
|
||||
setCreateModel(state, action: PayloadAction<string>) {
|
||||
state.createForm.model = action.payload
|
||||
},
|
||||
setCreateNotes(state, action: PayloadAction<string>) {
|
||||
state.createForm.notes = action.payload
|
||||
},
|
||||
setCreateIsActive(state, action: PayloadAction<boolean>) {
|
||||
state.createForm.isActive = action.payload
|
||||
},
|
||||
closeLendModal(state) {
|
||||
state.lendModalCameraId = null
|
||||
},
|
||||
setLendBorrower(state, action: PayloadAction<string>) {
|
||||
state.lendForm.borrower = action.payload
|
||||
},
|
||||
setLendPurpose(state, action: PayloadAction<string>) {
|
||||
state.lendForm.purpose = action.payload
|
||||
},
|
||||
setLendDueDate(state, action: PayloadAction<string>) {
|
||||
state.lendForm.dueDate = action.payload
|
||||
},
|
||||
openEditModal(state, action: PayloadAction<CameraListItem>) {
|
||||
const camera = action.payload
|
||||
state.editModalCameraId = camera.cameraId
|
||||
state.editForm = {
|
||||
name: camera.name,
|
||||
location: camera.location,
|
||||
model: camera.model ?? '',
|
||||
notes: camera.notes ?? '',
|
||||
isActive: camera.isActive,
|
||||
}
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
closeEditModal(state) {
|
||||
state.editModalCameraId = null
|
||||
},
|
||||
setEditName(state, action: PayloadAction<string>) {
|
||||
state.editForm.name = action.payload
|
||||
},
|
||||
setEditLocation(state, action: PayloadAction<string>) {
|
||||
state.editForm.location = action.payload
|
||||
},
|
||||
setEditModel(state, action: PayloadAction<string>) {
|
||||
state.editForm.model = action.payload
|
||||
},
|
||||
setEditNotes(state, action: PayloadAction<string>) {
|
||||
state.editForm.notes = action.payload
|
||||
},
|
||||
setEditIsActive(state, action: PayloadAction<boolean>) {
|
||||
state.editForm.isActive = action.payload
|
||||
},
|
||||
closeAuditModal(state) {
|
||||
state.auditModalCameraId = null
|
||||
state.auditLogs = []
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchCameras.pending, (state) => {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchCameras.fulfilled, (state, action) => {
|
||||
state.loading = false
|
||||
state.cameras = action.payload
|
||||
})
|
||||
.addCase(fetchCameras.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to fetch camera list'
|
||||
})
|
||||
.addCase(createCamera.pending, (state) => {
|
||||
state.isCreating = true
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(createCamera.fulfilled, (state, action) => {
|
||||
state.isCreating = false
|
||||
state.createModalOpen = false
|
||||
state.message = `Camera ${action.payload} created successfully`
|
||||
})
|
||||
.addCase(createCamera.rejected, (state, action) => {
|
||||
state.isCreating = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to create camera'
|
||||
})
|
||||
.addCase(simulateCameraIngest.pending, (state, action) => {
|
||||
state.simulatingCameraId = action.meta.arg
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(simulateCameraIngest.fulfilled, (state, action) => {
|
||||
state.simulatingCameraId = null
|
||||
state.message = `Simulated ingest for ${action.payload}`
|
||||
})
|
||||
.addCase(simulateCameraIngest.rejected, (state, action) => {
|
||||
state.simulatingCameraId = null
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to simulate camera ingest'
|
||||
})
|
||||
.addCase(submitCameraLend.pending, (state) => {
|
||||
state.isLending = true
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(submitCameraLend.fulfilled, (state, action) => {
|
||||
state.isLending = false
|
||||
state.lendModalCameraId = null
|
||||
state.lendForm = { borrower: '', purpose: '', dueDate: '' }
|
||||
state.message = `Camera ${action.payload} lent successfully`
|
||||
})
|
||||
.addCase(submitCameraLend.rejected, (state, action) => {
|
||||
state.isLending = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to lend camera'
|
||||
})
|
||||
.addCase(returnCamera.pending, (state, action) => {
|
||||
state.isReturning = action.meta.arg
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(returnCamera.fulfilled, (state, action) => {
|
||||
state.isReturning = null
|
||||
state.message = `Camera ${action.payload} returned successfully`
|
||||
})
|
||||
.addCase(returnCamera.rejected, (state, action) => {
|
||||
state.isReturning = null
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to return camera'
|
||||
})
|
||||
.addCase(saveCameraUpdates.pending, (state) => {
|
||||
state.isUpdating = true
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(saveCameraUpdates.fulfilled, (state, action) => {
|
||||
state.isUpdating = false
|
||||
state.editModalCameraId = null
|
||||
state.message = `Camera ${action.payload} updated successfully`
|
||||
})
|
||||
.addCase(saveCameraUpdates.rejected, (state, action) => {
|
||||
state.isUpdating = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to update camera'
|
||||
})
|
||||
.addCase(toggleCameraActivation.pending, (state, action) => {
|
||||
state.isTogglingActivation = action.meta.arg.cameraId
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(toggleCameraActivation.fulfilled, (state, action) => {
|
||||
state.isTogglingActivation = null
|
||||
state.message = action.payload.nextActive
|
||||
? `Camera ${action.payload.cameraId} activated`
|
||||
: `Camera ${action.payload.cameraId} deactivated`
|
||||
})
|
||||
.addCase(toggleCameraActivation.rejected, (state, action) => {
|
||||
state.isTogglingActivation = null
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to update camera activation status'
|
||||
})
|
||||
.addCase(fetchCameraAudit.pending, (state, action) => {
|
||||
state.auditModalCameraId = action.meta.arg
|
||||
state.auditLogs = []
|
||||
state.isAuditLoading = true
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchCameraAudit.fulfilled, (state, action) => {
|
||||
state.isAuditLoading = false
|
||||
state.auditModalCameraId = action.payload.cameraId
|
||||
state.auditLogs = action.payload.items
|
||||
})
|
||||
.addCase(fetchCameraAudit.rejected, (state, action) => {
|
||||
state.isAuditLoading = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to load audit logs'
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const {
|
||||
clearBanner,
|
||||
openCreateModal,
|
||||
closeCreateModal,
|
||||
setCreateCameraId,
|
||||
setCreateName,
|
||||
setCreateLocation,
|
||||
setCreateModel,
|
||||
setCreateNotes,
|
||||
setCreateIsActive,
|
||||
openLendModal,
|
||||
closeLendModal,
|
||||
setLendBorrower,
|
||||
setLendPurpose,
|
||||
setLendDueDate,
|
||||
openEditModal,
|
||||
closeEditModal,
|
||||
setEditName,
|
||||
setEditLocation,
|
||||
setEditModel,
|
||||
setEditNotes,
|
||||
setEditIsActive,
|
||||
closeAuditModal,
|
||||
} = cameraPageSlice.actions
|
||||
|
||||
export default cameraPageSlice.reducer
|
||||
Reference in new issue
Block a user