diff --git a/Dockerfile-test b/Dockerfile-test index e73b86f1..c3d7b649 100644 --- a/Dockerfile-test +++ b/Dockerfile-test @@ -7,7 +7,7 @@ COPY ./ $HOME/ RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y curl git -RUN export NODEV='20.12.2' \ +RUN export NODEV='20.17.0' \ && curl "https://nodejs.org/dist/v${NODEV}/node-v${NODEV}-linux-x64.tar.gz" | tar -xzv \ && cp ./node-v${NODEV}-linux-x64/bin/node /usr/bin/ \ && ./node-v${NODEV}-linux-x64/bin/npm install -g npm diff --git a/package-lock.json b/package-lock.json index def90141..24831ac8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15320,7 +15320,6 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.4.tgz", "integrity": "sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==", "hasInstallScript": true, - "license": "Apache-2.0", "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", @@ -17046,4 +17045,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 32e71807..b6ac9b4c 100644 --- a/package.json +++ b/package.json @@ -77,4 +77,4 @@ "typescript": "^5.4.4", "undici": "^6.0.0" } -} \ No newline at end of file +} diff --git a/src/@types/graphql.ts b/src/@types/graphql.ts index 8518cb8f..821605ff 100644 --- a/src/@types/graphql.ts +++ b/src/@types/graphql.ts @@ -256,6 +256,10 @@ export type CreateViewPayload = { view?: Maybe; }; +export type DeleteCameraInput = { + cameraId: Scalars['ID']['input']; +}; + export type DeleteDeploymentInput = { cameraId: Scalars['ID']['input']; deploymentId: Scalars['ID']['input']; @@ -602,6 +606,7 @@ export type Mutation = { createUpload?: Maybe; createUser?: Maybe; createView?: Maybe; + deleteCameraConfig?: Maybe; deleteDeployment?: Maybe; deleteImageComment?: Maybe; deleteImageTag?: Maybe; @@ -722,6 +727,11 @@ export type MutationCreateViewArgs = { }; +export type MutationDeleteCameraConfigArgs = { + input: DeleteCameraInput; +}; + + export type MutationDeleteDeploymentArgs = { input: DeleteDeploymentInput; }; diff --git a/src/api/auth/roles.ts b/src/api/auth/roles.ts index 78c455c9..2e17759f 100644 --- a/src/api/auth/roles.ts +++ b/src/api/auth/roles.ts @@ -15,6 +15,7 @@ const WRITE_DEPLOYMENTS_ROLES = [MANAGER]; const WRITE_AUTOMATION_RULES_ROLES = [MANAGER]; const WRITE_CAMERA_REGISTRATION_ROLES = [MANAGER]; const WRITE_CAMERA_SERIAL_NUMBER_ROLES = [MANAGER]; +const WRITE_DELETE_CAMERA_ROLES = [MANAGER]; const WRITE_TAGS_ROLES = [MANAGER, MEMBER]; export { @@ -31,5 +32,6 @@ export { WRITE_AUTOMATION_RULES_ROLES, WRITE_CAMERA_REGISTRATION_ROLES, WRITE_CAMERA_SERIAL_NUMBER_ROLES, + WRITE_DELETE_CAMERA_ROLES, WRITE_TAGS_ROLES, }; diff --git a/src/api/db/models/Camera.ts b/src/api/db/models/Camera.ts index 3c63a6b5..664d45bf 100644 --- a/src/api/db/models/Camera.ts +++ b/src/api/db/models/Camera.ts @@ -6,6 +6,7 @@ import retry from 'async-retry'; import { WRITE_CAMERA_REGISTRATION_ROLES, WRITE_CAMERA_SERIAL_NUMBER_ROLES, + WRITE_DELETE_CAMERA_ROLES, } from '../../auth/roles.js'; import { ProjectModel } from './Project.js'; import { BaseAuthedModel, MethodParams, roleCheck, idMatch } from './utils.js'; @@ -236,6 +237,69 @@ export class CameraModel { } } + // NOTE: this function is called by the async task handler as part of the delete camera task + static async removeProjectRegistration( + input: { cameraId: string }, + context: Pick, + ): Promise<{ wirelessCameras: WirelessCameraSchema[]; project?: ProjectSchema }> { + const projectId = context.user['curr_project']; + + try { + const wirelessCameras = await WirelessCamera.find(); + const cam = wirelessCameras.find((c) => idMatch(c._id, input.cameraId)); + + if (!cam) { + const msg = `Couldn't find camera record for camera ${input.cameraId}`; + throw new CameraRegistrationError(msg); + } + const activeReg = cam.projRegistrations.find((pr) => pr.active); + + // if active registration === curr_project, + // set default_project registration to active + if (activeReg?.projectId === projectId) { + const defaultProjReg = cam.projRegistrations.find( + (pr) => pr.projectId === 'default_project', + ); + if (defaultProjReg) defaultProjReg.active = true; + else { + cam.projRegistrations.push({ + _id: new ObjectId(), + projectId: 'default_project', + active: true, + }); + } + } + const currProjIndex = cam.projRegistrations.findIndex((pr) => pr.projectId === projectId); + cam.projRegistrations.splice(currProjIndex, 1); + await cam.save(); + + // make sure there's a Project.cameraConfig record for this camera + // in the default_project and create one if not + let defaultProj = await Project.findOne({ _id: 'default_project' }); + if (!defaultProj) { + throw new CameraRegistrationError('Could not find default project'); + } + + let addedNewCamConfig = false; + const camConfig = defaultProj.cameraConfigs.find((cc) => idMatch(cc._id, input.cameraId)); + if (!camConfig) { + defaultProj = (await ProjectModel.createCameraConfig( + { + projectId: 'default_project', + cameraId: input.cameraId, + }, + context, + ))!; + addedNewCamConfig = true; + } + + return { wirelessCameras, ...(addedNewCamConfig && { project: defaultProj }) }; + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + static async updateSerialNumberTask( input: gql.UpdateCameraSerialNumberInput, context: Pick, @@ -380,6 +444,27 @@ export class CameraModel { throw new InternalServerError(err as string); } } + + static async deleteCameraTask( + input: gql.DeleteCameraInput, + context: Pick, + ): Promise> { + try { + console.log('CameraModel.deleteCameraTask - input: ', input); + return await TaskModel.create( + { + type: 'DeleteCamera', + projectId: context.user['curr_project'], + user: context.user.sub, + config: input, + }, + context, + ); + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } } export default class AuthedCameraModel extends BaseAuthedModel { @@ -405,6 +490,11 @@ export default class AuthedCameraModel extends BaseAuthedModel { async updateSerialNumber(...args: MethodParams) { return await CameraModel.updateSerialNumberTask(...args); } + + @roleCheck(WRITE_DELETE_CAMERA_ROLES) + async deleteCameraConfig(...args: MethodParams) { + return await CameraModel.deleteCameraTask(...args); + } } interface OperationMetadata { diff --git a/src/api/db/models/Project.ts b/src/api/db/models/Project.ts index 7894f81a..727d5162 100644 --- a/src/api/db/models/Project.ts +++ b/src/api/db/models/Project.ts @@ -172,7 +172,7 @@ export class ProjectModel { static async createCameraConfig( input: { projectId: string; cameraId: string }, - context: Pick, + _: Pick, ): Promise> { console.log('Project.createCameraConfig - input: ', input); const { projectId, cameraId } = input; @@ -238,6 +238,39 @@ export class ProjectModel { } } + static async deleteCameraConfig( + input: { cameraId: string }, + context: Pick, + ): Promise> { + try { + return await retry( + async () => { + let project = await Project.findOne({ _id: context.user['curr_project'] }); + if (!project) throw new NotFoundError('Project not found'); + console.log('originalProject: ', project); + + console.log('Deleting camera config with _id: ', input.cameraId); + // NOTE: using findOneAndUpdate() to update Projects to preserve atomicity of the + // operation and avoid race conditions + const updatedProject = await Project.findOneAndUpdate( + { _id: context.user['curr_project'] }, + { + $pull: { cameraConfigs: { _id: input.cameraId } }, + }, + { returnDocument: 'after' }, + ); + + console.log('updatedProject: ', updatedProject); + return updatedProject!; + }, + { retries: 2 }, + ); + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + static async createView( input: gql.CreateViewInput, context: Pick, @@ -288,7 +321,7 @@ export class ProjectModel { bail(new ForbiddenError(`View ${view?.name} is not editable`)); } - // appy updates & save project + // apply updates & save project Object.assign(view, input.diffs); const updatedProj = await project.save(); return updatedProj.views.find((v): v is HydratedSingleSubdocument => @@ -334,6 +367,49 @@ export class ProjectModel { } } + static async removeCameraFromViews( + input: { cameraId: string }, + context: Pick, + ): Promise> { + try { + return await retry( + async () => { + // find project + let project = await Project.findOne({ _id: context.user['curr_project'] }); + if (!project) throw new NotFoundError('Project not found'); + + // get all deployment ids for the camera + const projectDeps = + project.cameraConfigs + .find((cc) => cc._id === input.cameraId) + ?.deployments.map((d) => d._id.toString()) ?? []; + + console.log('deployments to be removed from views: ', projectDeps); + + project.views.forEach((v, index) => { + // if view filters has the camera id or any of the deployment ids + if ( + v.filters.cameras?.includes(input.cameraId) || + projectDeps.some((d) => v.filters.deployments?.includes(d)) + ) { + v.filters.cameras = v.filters.cameras?.filter((c) => c !== input.cameraId); + v.filters.deployments = v.filters.deployments?.filter( + (d) => !projectDeps.includes(d), + ); + project.views[index] = v; + } + }); + + return project.save(); + }, + { retries: 2 }, + ); + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + static async updateAutomationRules( { automationRules }: gql.UpdateAutomationRulesInput, context: Pick, diff --git a/src/api/db/models/Task.ts b/src/api/db/models/Task.ts index d3a58b8b..8997f2fb 100644 --- a/src/api/db/models/Task.ts +++ b/src/api/db/models/Task.ts @@ -66,7 +66,7 @@ export class TaskModel { projectId: input.projectId, type: input.type, }); - + console.log('TaskModel.create task: ', task); const sqs = new SQS.SQSClient({ region: process.env.AWS_DEFAULT_REGION }); await task.save(); diff --git a/src/api/db/models/utils.ts b/src/api/db/models/utils.ts index 7f86d95c..db78c6bc 100644 --- a/src/api/db/models/utils.ts +++ b/src/api/db/models/utils.ts @@ -38,11 +38,11 @@ export function buildTagPipeline(tags: string[]): PipelineStage[] { pipeline.push({ $match: { - tags: { $in: tags } - } + tags: { $in: tags }, + }, }); - return pipeline + return pipeline; } export function buildLabelPipeline(labels: string[]): PipelineStage[] { @@ -194,7 +194,7 @@ export function buildPipeline( } // match reviewedFilter - if (reviewed !== null) { + if (reviewed !== null && reviewed !== undefined) { pipeline.push({ $match: { reviewed: reviewed, diff --git a/src/api/db/schemas/Task.ts b/src/api/db/schemas/Task.ts index 4a0bb6d6..83fd69f9 100644 --- a/src/api/db/schemas/Task.ts +++ b/src/api/db/schemas/Task.ts @@ -20,6 +20,7 @@ const TaskSchema = new Schema({ 'UpdateSerialNumber', 'DeleteImages', 'DeleteImagesByFilter', + 'DeleteCamera', ], }, status: { diff --git a/src/api/resolvers/Mutation.ts b/src/api/resolvers/Mutation.ts index 5f2c980d..9eff1b3f 100644 --- a/src/api/resolvers/Mutation.ts +++ b/src/api/resolvers/Mutation.ts @@ -188,6 +188,15 @@ export default { return context.models.Camera.updateSerialNumber(input, context); }, + deleteCameraConfig: async ( + _: unknown, + { input }: gql.MutationDeleteCameraConfigArgs, + context: Context, + ): Promise => { + console.log('Mutation.deleteCamera input:', input); + return context.models.Camera.deleteCameraConfig(input, context); + }, + createProject: async ( _: unknown, { input }: gql.MutationCreateProjectArgs, diff --git a/src/api/type-defs/inputs/DeleteCameraInput.ts b/src/api/type-defs/inputs/DeleteCameraInput.ts new file mode 100644 index 00000000..4d690ff5 --- /dev/null +++ b/src/api/type-defs/inputs/DeleteCameraInput.ts @@ -0,0 +1,5 @@ +export default /* GraphQL */ ` + input DeleteCameraInput { + cameraId: ID! + } +`; diff --git a/src/api/type-defs/root/Mutation.ts b/src/api/type-defs/root/Mutation.ts index a19b9517..e20c9782 100644 --- a/src/api/type-defs/root/Mutation.ts +++ b/src/api/type-defs/root/Mutation.ts @@ -41,6 +41,7 @@ export default /* GraphQL */ ` registerCamera(input: RegisterCameraInput!): RegisterCameraPayload unregisterCamera(input: UnregisterCameraInput!): UnregisterCameraPayload updateCameraSerialNumber(input: UpdateCameraSerialNumberInput!): Task + deleteCameraConfig(input: DeleteCameraInput!): Task createView(input: CreateViewInput!): CreateViewPayload updateView(input: UpdateViewInput!): UpdateViewPayload diff --git a/src/task/camera.ts b/src/task/camera.ts index 13211533..e0a25464 100644 --- a/src/task/camera.ts +++ b/src/task/camera.ts @@ -2,8 +2,56 @@ import { type User } from '../api/auth/authorization.js'; import { CameraModel } from '../api/db/models/Camera.js'; import { type TaskInput } from '../api/db/models/Task.js'; import type * as gql from '../@types/graphql.js'; +import { ProjectModel } from '../api/db/models/Project.js'; +import { DeleteImagesByFilter } from './image.js'; export async function UpdateSerialNumber(task: TaskInput) { const context = { user: { is_superuser: true, curr_project: task.projectId } as User }; return await CameraModel.updateSerialNumber(task.config, context); } + +export async function DeleteCamera(task: TaskInput) { + const context = { user: { is_superuser: true, curr_project: task.projectId } as User }; + console.log('CameraModel.deleteCameraConfig - input: ', task.config); + const errors = []; + try { + // Step 1: delete deployments from views + await ProjectModel.removeCameraFromViews( + { + cameraId: task.config.cameraId, + }, + context, + ); + // Step 2: delete camera record from project + await ProjectModel.deleteCameraConfig( + { + cameraId: task.config.cameraId, + }, + context, + ); + + // Step3: delete images associated with this camera + const deleteRes = await DeleteImagesByFilter({ + projectId: task.projectId, + config: { + filters: { + cameras: [task.config.cameraId], + }, + }, + type: 'DeleteImagesByFilter', + user: task.user, + }); + if (deleteRes.errors) { + errors.push(...deleteRes.errors); + } + // Step 4: unregister camera + if ( + (await CameraModel.getWirelessCameras({ _ids: [task.config.cameraId] }, context)).length > 0 + ) { + await CameraModel.removeProjectRegistration({ cameraId: task.config.cameraId }, context); + } + } catch (err) { + return { isOk: false, error: err }; + } + return { isOk: true, errors: errors }; +} diff --git a/src/task/handler.ts b/src/task/handler.ts index 84ea05be..ebb5a71b 100644 --- a/src/task/handler.ts +++ b/src/task/handler.ts @@ -4,7 +4,7 @@ import { connectToDatabase } from '../api/db/connect.js'; import { TaskModel } from '../api/db/models/Task.js'; import GetStats from './stats.js'; import { CreateDeployment, UpdateDeployment, DeleteDeployment } from './deployment.js'; -import { UpdateSerialNumber } from './camera.js'; +import { DeleteCamera, UpdateSerialNumber } from './camera.js'; import ImageErrorExport from './image-errors.js'; import AnnotationsExport from './annotations.js'; import { parseMessage } from './utils.js'; @@ -27,7 +27,7 @@ async function handler(event: SQSEvent) { { _id: task._id, status: 'RUNNING' }, { user: { curr_project: task.projectId } as User }, ); - + console.log('TaskModel task.type:', task.type); try { if (task.type === 'GetStats') { output = await GetStats(task); @@ -47,6 +47,8 @@ async function handler(event: SQSEvent) { output = await DeleteImages(task); } else if (task.type === 'DeleteImagesByFilter') { output = await DeleteImagesByFilter(task); + } else if (task.type === 'DeleteCamera') { + output = await DeleteCamera(task); } else { throw new Error(`Unknown Task: ${JSON.stringify(task)}`); } diff --git a/src/task/image.ts b/src/task/image.ts index 97b598a1..f5b34562 100644 --- a/src/task/image.ts +++ b/src/task/image.ts @@ -15,9 +15,14 @@ export async function DeleteImagesByFilter(task: TaskInput 0) { const batch = images.results.map((image) => image._id); - await ImageModel.deleteImages({ imageIds: batch }, context); + const res = await ImageModel.deleteImages({ imageIds: batch }, context); + if (res.errors) { + errors.push(...res.errors); + } if (images.hasNext) { images = await ImageModel.queryByFilter( { @@ -32,7 +37,7 @@ export async function DeleteImagesByFilter(task: TaskInput) { @@ -44,9 +49,13 @@ export async function DeleteImages(task: TaskInput) { */ const context = { user: { is_superuser: true, curr_project: task.projectId } as User }; const imagesToDelete = task.config.imageIds?.slice() ?? []; + const errors = []; while (imagesToDelete.length > 0) { const batch = imagesToDelete.splice(0, ImageModel.DELETE_IMAGES_BATCH_SIZE); - await ImageModel.deleteImages({ imageIds: batch }, context); + const res = await ImageModel.deleteImages({ imageIds: batch }, context); + if (res.errors) { + errors.push(...res.errors); + } } - return { imageIds: task.config.imageIds }; + return { imageIds: task.config.imageIds, errors: errors }; } diff --git a/src/task/stats.ts b/src/task/stats.ts index b16eabe7..d74d7bbb 100644 --- a/src/task/stats.ts +++ b/src/task/stats.ts @@ -18,7 +18,7 @@ export default async function (task: TaskInput<{ filters: FiltersSchema }>) { const project = await ProjectModel.queryById(context.user['curr_project']); const pipeline = buildPipeline(task.config.filters, context.user['curr_project']); - + console.log('GetStats pipeline:', pipeline); // stream in images from MongoDB for await (const img of Image.aggregate(pipeline)) { // increment imageCount