From b10748f01a119d5fe7a489ac9d93f6afd740080d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 6 Feb 2026 13:25:07 +0545 Subject: [PATCH 1/4] feat(OUT-3072): support task association while creating task - [x] migration that renames column: viewers to associations and add shared column - [x] association selector and share with client toggle components - [x] task can be shared only if assocaition is selected and assignee is IU - [x] task can be created with no assignee and only associations - [x] zod validation to meet association and shared conditions --- .../migration.sql | 9 ++ prisma/schema/task.prisma | 3 +- src/app/api/tasks/public/public.dto.ts | 8 +- src/app/api/tasks/public/public.serializer.ts | 4 +- src/app/api/tasks/subtasks.service.ts | 8 +- src/app/api/tasks/tasks.logger.ts | 8 +- src/app/api/tasks/tasks.service.ts | 51 ++++++----- src/app/api/tasks/tasksShared.service.ts | 32 +++---- src/app/detail/ui/NewTaskCard.tsx | 88 +++++++++++++------ src/app/globals.css | 19 ++++ src/app/ui/Modal_NewTaskForm.tsx | 6 +- src/app/ui/NewTaskForm.tsx | 56 +++++++++--- src/components/inputs/CustomToggle.tsx | 15 ++++ src/redux/features/createTaskSlice.ts | 26 ++++-- src/theme/theme.ts | 2 +- src/types/dto/tasks.dto.ts | 47 ++++++++-- tailwind.config.ts | 2 +- 17 files changed, 270 insertions(+), 114 deletions(-) create mode 100644 prisma/migrations/20260205081231_add_associations_is_shared_column_in_tasks_table/migration.sql create mode 100644 src/components/inputs/CustomToggle.tsx diff --git a/prisma/migrations/20260205081231_add_associations_is_shared_column_in_tasks_table/migration.sql b/prisma/migrations/20260205081231_add_associations_is_shared_column_in_tasks_table/migration.sql new file mode 100644 index 000000000..8dea216e4 --- /dev/null +++ b/prisma/migrations/20260205081231_add_associations_is_shared_column_in_tasks_table/migration.sql @@ -0,0 +1,9 @@ +/* + - This query renames viewers to associations and add isShared column. +*/ +-- AlterTable +ALTER TABLE "Tasks" +RENAME COLUMN "viewers" TO "associations"; + +ALTER TABLE "Tasks" +ADD COLUMN "isShared" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema/task.prisma b/prisma/schema/task.prisma index ae8f67ace..ab11a4d23 100644 --- a/prisma/schema/task.prisma +++ b/prisma/schema/task.prisma @@ -58,7 +58,8 @@ model Task { taskUpdateBacklogs TaskUpdateBacklog[] - viewers Json[] @db.JsonB @default([]) + associations Json[] @db.JsonB @default([]) + isShared Boolean @default(false) @@index([path], type: Gist) @@map("Tasks") diff --git a/src/app/api/tasks/public/public.dto.ts b/src/app/api/tasks/public/public.dto.ts index e909f25a8..7629e35a2 100644 --- a/src/app/api/tasks/public/public.dto.ts +++ b/src/app/api/tasks/public/public.dto.ts @@ -2,7 +2,7 @@ import { RFC3339DateSchema } from '@/types/common' import { CopilotAPI } from '@/utils/CopilotAPI' import { AssigneeType } from '@prisma/client' import { z } from 'zod' -import { validateUserIds, ViewersSchema } from '@/types/dto/tasks.dto' +import { validateUserIds, AssociationsSchema } from '@/types/dto/tasks.dto' import { PublicAttachmentDtoSchema } from '@/app/api/attachments/public/public.dto' export const TaskSourceSchema = z.enum(['web', 'api']) @@ -41,7 +41,7 @@ export const PublicTaskDtoSchema = z.object({ internalUserId: z.string().uuid().nullable(), clientId: z.string().uuid().nullable(), companyId: z.string().uuid().nullable(), - viewers: ViewersSchema, + viewers: AssociationsSchema, attachments: z.array(PublicAttachmentDtoSchema), }) export type PublicTaskDto = z.infer @@ -59,7 +59,7 @@ export const publicTaskCreateDtoSchemaFactory = (token: string) => { internalUserId: z.string().uuid().optional(), clientId: z.string().uuid().optional(), companyId: z.string().uuid().optional(), - viewers: ViewersSchema, //right now, we only need the feature to have max of 1 viewer per task + viewers: AssociationsSchema, //right now, we only need the feature to have max of 1 viewer per task }) .superRefine(async (data, ctx) => { const { name, templateId, internalUserId, clientId, status } = data @@ -153,7 +153,7 @@ export const PublicTaskUpdateDtoSchema = z internalUserId: z.string().uuid().nullish(), clientId: z.string().uuid().nullish(), companyId: z.string().uuid().nullish(), - viewers: ViewersSchema, + viewers: AssociationsSchema, }) .superRefine(validateUserIds) diff --git a/src/app/api/tasks/public/public.serializer.ts b/src/app/api/tasks/public/public.serializer.ts index 1279802e7..53a061a49 100644 --- a/src/app/api/tasks/public/public.serializer.ts +++ b/src/app/api/tasks/public/public.serializer.ts @@ -7,7 +7,7 @@ import { CreateTaskRequestSchema, UpdateTaskRequest, UpdateTaskRequestSchema, - ViewersSchema, + AssociationsSchema, } from '@/types/dto/tasks.dto' import { rfc3339ToDateString, toRFC3339 } from '@/utils/dateHelper' import { sanitizeHtml } from '@/utils/santizeContents' @@ -63,7 +63,7 @@ export class PublicTaskSerializer { internalUserId: task.internalUserId, clientId: task.clientId, companyId: task.companyId, - viewers: ViewersSchema.parse(task.viewers), + viewers: AssociationsSchema.parse(task.associations), attachments: await PublicAttachmentSerializer.serializeAttachments({ attachments: task.attachments, uploadedByUserType: 'internalUser', // task creator is always IU diff --git a/src/app/api/tasks/subtasks.service.ts b/src/app/api/tasks/subtasks.service.ts index af9429f83..164118b73 100644 --- a/src/app/api/tasks/subtasks.service.ts +++ b/src/app/api/tasks/subtasks.service.ts @@ -1,5 +1,5 @@ import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' -import { ViewersSchema, ViewerType } from '@/types/dto/tasks.dto' +import { AssociationsSchema, ViewerType } from '@/types/dto/tasks.dto' import { CopilotAPI } from '@/utils/CopilotAPI' import { buildLtreeNodeString } from '@/utils/ltree' import APIError from '@api/core/exceptions/api' @@ -13,7 +13,7 @@ interface Assignable { internalUserId: string | null clientId: string | null companyId: string | null - viewers: JsonValue[] + associations: JsonValue[] } export class SubtaskService extends BaseService { @@ -127,8 +127,8 @@ export class SubtaskService extends BaseService { latestAccessibleTaskIndex = tasks.findLastIndex((task) => { let viewer: ViewerType | undefined // check if viewer exists and parse and assign viewer - if (Array.isArray(task.viewers) && !!task.viewers.length) { - viewer = ViewersSchema.parse(task.viewers)?.[0] + if (Array.isArray(task.associations) && !!task.associations.length) { + viewer = AssociationsSchema.parse(task.associations)?.[0] } return !( diff --git a/src/app/api/tasks/tasks.logger.ts b/src/app/api/tasks/tasks.logger.ts index e836ec76f..f2df393ab 100644 --- a/src/app/api/tasks/tasks.logger.ts +++ b/src/app/api/tasks/tasks.logger.ts @@ -11,7 +11,7 @@ import User from '@api/core/models/User.model' import { BaseService } from '@api/core/services/base.service' import { ActivityType, AssigneeType, Task, WorkflowState } from '@prisma/client' import { ViewerAddedSchema, ViewerRemovedSchema } from '@api/activity-logs/schemas/ViewerSchema' -import { ViewersSchema } from '@/types/dto/tasks.dto' +import { AssociationsSchema } from '@/types/dto/tasks.dto' /** * Wrapper over ActivityLogger to implement a clean abstraction for task creation / update events @@ -55,9 +55,9 @@ export class TasksActivityLogger extends BaseService { } } - if (Array.isArray(this.task.viewers) && Array.isArray(prevTask.viewers)) { - const currentViewers = ViewersSchema.parse(this.task.viewers) || [] - const prevViewers = ViewersSchema.parse(prevTask.viewers) || [] + if (Array.isArray(this.task.associations) && Array.isArray(prevTask.associations)) { + const currentViewers = AssociationsSchema.parse(this.task.associations) || [] + const prevViewers = AssociationsSchema.parse(prevTask.associations) || [] if ( (!!currentViewers.length || !!prevViewers.length) && diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index af8b9a405..a65551e6d 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -5,10 +5,9 @@ import { TaskWithWorkflowState } from '@/types/db' import { AncestorTaskResponse, CreateTaskRequest, - CreateTaskRequestSchema, UpdateTaskRequest, - Viewers, - ViewersSchema, + Associations, + AssociationsSchema, } from '@/types/dto/tasks.dto' import { DISPATCHABLE_EVENT } from '@/types/webhook' import { UserIdsType } from '@/utils/assignee' @@ -159,13 +158,16 @@ export class TasksService extends TasksSharedService { // NOTE: This block strictly doesn't allow clients to create tasks let createdById = z.string().parse(this.user.internalUserId) - let viewers: Viewers = [] - if (data.viewers?.length) { - if (!validatedIds.internalUserId) { - throw new APIError(httpStatus.BAD_REQUEST, `Task cannot be created with viewers if its not assigned to an IU.`) + let associations: Associations = [] + if (data.associations?.length) { + if (!!data.isShared && !validatedIds.internalUserId) { + throw new APIError( + httpStatus.BAD_REQUEST, + `Task cannot be created and shared with associations if its not assigned to an IU.`, + ) } - viewers = await this.validateViewers(data.viewers) - console.info('TasksService#createTask | Viewers validated for task:', viewers) + associations = await this.validateViewers(data.associations) + console.info('TasksService#createTask | Associations validated for task:', associations) } // Create a new task associated with current workspaceId. Also inject current request user as the creator. @@ -180,7 +182,8 @@ export class TasksService extends TasksSharedService { source: Source.web, assigneeId, assigneeType, - viewers: viewers, + associations, + isShared: data.isShared, ...validatedIds, ...(opts?.manualTimestamp && { createdAt: opts.manualTimestamp }), ...(await getTaskTimestamps('create', this.user, data, undefined, workflowStateStatus)), @@ -344,14 +347,14 @@ export class TasksService extends TasksSharedService { companyId: validatedIds?.companyId ?? null, }) - let viewers: Viewers = ViewersSchema.parse(prevTask.viewers) + let associations: Associations = AssociationsSchema.parse(prevTask.associations) const viewersResetCondition = shouldUpdateUserIds ? !!clientId || !!companyId : !prevTask.internalUserId - if (data.viewers) { - // only update of viewers attribute is available. No viewers in payload attribute means the data remains as it is in DB. - if (viewersResetCondition || !data.viewers?.length) { - viewers = [] // reset viewers to [] if task is not reassigned to IU. - } else if (data.viewers?.length) { - viewers = await this.validateViewers(data.viewers) + if (data.associations) { + // only update of associations attribute is available. No associations in payload attribute means the data remains as it is in DB. + if (viewersResetCondition || !data.associations?.length) { + associations = [] // reset associations to [] if task is not reassigned to IU. + } else if (data.associations?.length) { + associations = await this.validateViewers(data.associations) } } @@ -401,7 +404,7 @@ export class TasksService extends TasksSharedService { archivedBy, completedBy, completedByUserType, - viewers, + associations, ...userAssignmentFields, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, @@ -518,7 +521,7 @@ export class TasksService extends TasksSharedService { { assigneeId, assigneeType: AssigneeType.company }, { companyId: assigneeId, clientId: null }, { - viewers: { + associations: { hasSome: [{ clientId: null, companyId: assigneeId }], }, }, @@ -554,12 +557,12 @@ export class TasksService extends TasksSharedService { async resetAllSharedTasks(assigneeId: string) { const tasks = await this.db.task.findMany({ where: { - viewers: { hasSome: [{ clientId: assigneeId }, { companyId: assigneeId }] }, + associations: { hasSome: [{ clientId: assigneeId }, { companyId: assigneeId }] }, workspaceId: this.user.workspaceId, }, }) if (!tasks.length) { - // If viewers doesn't have an associated task at all, skip logic + // If associations doesn't have an associated task at all, skip logic return [] } const taskIds = tasks.map((task) => task.id) @@ -570,7 +573,7 @@ export class TasksService extends TasksSharedService { }, }, data: { - viewers: [], //note : if we support multiple viewers in the future, make sure to only pop out the deleted viewer among other viewers. + associations: [], //note : if we support multiple associations in the future, make sure to only pop out the deleted association among other associations. }, }) return tasks @@ -585,7 +588,7 @@ export class TasksService extends TasksSharedService { const { completedBy, completedByUserType } = await this.getCompletionInfo(targetWorkflowStateId) // Query previous task - const filters = this.buildTaskPermissions(id, false) // condition 'false' to exclude viewers from the query to get prev task. This will prevent viewer to update the task workflow status + const filters = this.buildTaskPermissions(id, false) // condition 'false' to exclude associations from the query to get prev task. This will prevent association to update the task workflow status const prevTask = await this.db.task.findFirst({ where: filters, relationLoadStrategy: 'join', @@ -654,7 +657,7 @@ export class TasksService extends TasksSharedService { clientId: true, companyId: true, internalUserId: true, - viewers: true, + associations: true, }, }), ) as Promise[], diff --git a/src/app/api/tasks/tasksShared.service.ts b/src/app/api/tasks/tasksShared.service.ts index fb96d6dcb..82ab6bb33 100644 --- a/src/app/api/tasks/tasksShared.service.ts +++ b/src/app/api/tasks/tasksShared.service.ts @@ -2,7 +2,7 @@ import { maxSubTaskDepth } from '@/constants/tasks' import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' import { InternalUsers, Uuid } from '@/types/common' import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' -import { CreateTaskRequest, CreateTaskRequestSchema, Viewers } from '@/types/dto/tasks.dto' +import { CreateTaskRequest, CreateTaskRequestSchema, Associations } from '@/types/dto/tasks.dto' import { getFileNameFromPath } from '@/utils/attachmentUtils' import { buildLtree, buildLtreeNodeString } from '@/utils/ltree' import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' @@ -62,7 +62,7 @@ export abstract class TasksSharedService extends BaseService { filters.push( // Get tasks that includes the client as a viewer { - viewers: { + associations: { hasSome: [{ clientId, companyId }, { companyId }], }, }, @@ -76,7 +76,7 @@ export abstract class TasksSharedService extends BaseService { filters.push( // Get tasks that includes the company as a viewer { - viewers: { + associations: { hasSome: [{ companyId }], }, }, @@ -156,7 +156,7 @@ export abstract class TasksSharedService extends BaseService { }, { NOT: { - viewers: { + associations: { hasSome: [ { clientId: this.user.clientId, companyId: this.user.companyId }, { companyId: this.user.companyId }, @@ -357,26 +357,26 @@ export abstract class TasksSharedService extends BaseService { return { completedBy: null, completedByUserType: null, workflowStateStatus: workflowState.type } } - protected async validateViewers(viewers: Viewers) { - if (!viewers?.length) return [] - const viewer = viewers[0] + protected async validateViewers(associations: Associations) { + if (!associations?.length) return [] + const association = associations[0] try { - if (viewer.clientId) { - const client = await this.copilot.getClient(viewer.clientId) //support looping viewers and filtering from getClients instead of doing getClient if we do support many viewers in the future. - if (!client.companyIds?.includes(viewers[0].companyId)) { - throw new APIError(httpStatus.BAD_REQUEST, 'Invalid companyId for the provided viewer.') + if (association.clientId) { + const client = await this.copilot.getClient(association.clientId) //support looping associations and filtering from getClients instead of doing getClient if we do support many associations in the future. + if (!client.companyIds?.includes(associations[0].companyId)) { + throw new APIError(httpStatus.BAD_REQUEST, 'Invalid companyId for the provided association.') } } else { - await this.copilot.getCompany(viewer.companyId) + await this.copilot.getCompany(association.companyId) } } catch (err) { if (err instanceof APIError) { throw err } - throw new APIError(httpStatus.BAD_REQUEST, `Viewer should be a CU.`) + throw new APIError(httpStatus.BAD_REQUEST, `Association should be a CU.`) } - return viewers + return associations } protected async updateTaskIdOfAttachmentsAfterCreation(htmlString: string, task_id: string) { @@ -495,7 +495,7 @@ export abstract class TasksSharedService extends BaseService { protected async createSubtasksFromTemplate(data: TaskTemplate, parentTask: Task, manualTimestamp: Date) { const { workspaceId, title, body, workflowStateId } = data const previewMode = Boolean(this.user.clientId || this.user.companyId) - const { id: parentId, internalUserId, clientId, companyId, viewers } = parentTask + const { id: parentId, internalUserId, clientId, companyId, associations } = parentTask try { const createTaskPayload = CreateTaskRequestSchema.parse({ @@ -509,7 +509,7 @@ export abstract class TasksSharedService extends BaseService { internalUserId, clientId, companyId, - viewers, + associations, }), //On CRM view, we set assignee and viewers for subtasks same as the parent task. }) diff --git a/src/app/detail/ui/NewTaskCard.tsx b/src/app/detail/ui/NewTaskCard.tsx index c5105b79e..0349af20e 100644 --- a/src/app/detail/ui/NewTaskCard.tsx +++ b/src/app/detail/ui/NewTaskCard.tsx @@ -9,6 +9,7 @@ import { CopilotPopSelector } from '@/components/inputs/CopilotSelector' import { DatePickerComponent } from '@/components/inputs/DatePickerComponent' import Selector, { SelectorType } from '@/components/inputs/Selector' import { WorkflowStateSelector } from '@/components/inputs/Selector-WorkflowState' +import { CustomToggle } from '@/components/inputs/CustomToggle' import { StyledTextField } from '@/components/inputs/TextField' import { MAX_UPLOAD_LIMIT } from '@/constants/attachments' import { useHandleSelectorComponent } from '@/hooks/useHandleSelectorComponent' @@ -18,7 +19,7 @@ import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { selectTaskDetails } from '@/redux/features/taskDetailsSlice' import { selectCreateTemplate } from '@/redux/features/templateSlice' import { DateString } from '@/types/date' -import { CreateTaskRequest, Viewers } from '@/types/dto/tasks.dto' +import { CreateTaskRequest, Associations } from '@/types/dto/tasks.dto' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' import { FilterByOptions, IAssigneeCombined, InputValue, ITemplate, UserIds } from '@/types/interfaces' import { getAssigneeName, UserIdsType } from '@/utils/assignee' @@ -43,7 +44,8 @@ interface SubTaskFields { workflowStateId: string userIds: UserIdsType dueDate: DateString | null - viewers?: Viewers + associations?: Associations + isShared?: boolean } export const NewTaskCard = ({ @@ -91,13 +93,17 @@ export const NewTaskCard = ({ workflowStateId: todoWorkflowState.id, userIds: assigneeIds, dueDate: null, - viewers: [], + associations: [], + isShared: false, })) updateStatusValue(todoWorkflowState) setAssigneeValue(null) } - const handleFieldChange = (field: keyof SubTaskFields, value: string | DateString | null | UserIdsType | Viewers) => { + const handleFieldChange = ( + field: keyof SubTaskFields, + value: string | DateString | null | UserIdsType | Associations | boolean, + ) => { setSubTaskFields((prev) => ({ ...prev, [field]: value, @@ -139,14 +145,14 @@ export const NewTaskCard = ({ ? (getSelectorAssigneeFromFilterOptions(assignee, { internalUserId: null, ...previewClientCompany }) ?? null) // if preview mode, default select the respective client/company as assignee : null, ) - const [taskViewerValue, setTaskViewerValue] = useState( - !!previewMode - ? (getSelectorAssigneeFromFilterOptions( - assignee, - { internalUserId: null, ...previewClientCompany }, // if preview mode, default select the respective client/company as viewer - ) ?? null) - : null, - ) + const previewTaskAssociation = !!previewMode + ? (getSelectorAssigneeFromFilterOptions( + assignee, + { internalUserId: null, ...previewClientCompany }, // if preview mode, default select the respective client/company as viewer + ) ?? null) + : null + const [taskAssociationValue, setTaskAssociationValue] = useState(previewTaskAssociation) + const [isShared, setIsShared] = useState(!!previewTaskAssociation) const applyTemplate = useCallback( (id: string, templateTitle: string) => { @@ -224,7 +230,9 @@ export const NewTaskCard = ({ companyId: subTaskFields.userIds[UserIds.COMPANY_ID], dueDate: formattedDueDate, parentId: activeTask?.id, - ...(subTaskFields?.viewers && subTaskFields.viewers.length > 0 && { viewers: subTaskFields.viewers }), + ...(subTaskFields.associations && + subTaskFields.associations.length > 0 && { associations: subTaskFields.associations }), + isShared: subTaskFields.isShared, } handleSubTaskCreation(payload) @@ -233,21 +241,29 @@ export const NewTaskCard = ({ } const handleAssigneeChange = (inputValue: InputValue[]) => { - if (inputValue.length === 0 || inputValue[0].object !== UserRole.IU) { - setTaskViewerValue(null) - handleFieldChange('viewers', []) + if (inputValue.length && inputValue[0].object !== UserRole.IU) { + setTaskAssociationValue(null) + handleFieldChange('associations', []) } + if (inputValue.length) { + setIsShared(false) + handleFieldChange('isShared', false) + } + if (!!previewMode && inputValue.length && inputValue[0].object === UserRole.IU && previewClientCompany.companyId) { - if (!taskViewerValue) - setTaskViewerValue( + if (!taskAssociationValue) { + const viewerValue = getSelectorAssigneeFromFilterOptions( assignee, { internalUserId: null, ...previewClientCompany }, // if preview mode, default select the respective client/company as viewer - ) ?? null, - ) - handleFieldChange('viewers', [ + ) ?? null + setTaskAssociationValue(viewerValue) + setIsShared(!!viewerValue) + } + handleFieldChange('associations', [ { clientId: previewClientCompany.clientId || undefined, companyId: previewClientCompany.companyId }, ]) + handleFieldChange('isShared', true) } const newUserIds = getSelectedUserIds(inputValue) const selectedAssignee = getSelectorAssignee(assignee, inputValue) @@ -255,6 +271,10 @@ export const NewTaskCard = ({ handleFieldChange('userIds', newUserIds) } + const baseAssociationCondition = assigneeValue && assigneeValue.type === FilterByOptions.IUS + const showShareToggle = baseAssociationCondition && taskAssociationValue + const showAssociation = !assigneeValue || baseAssociationCondition + return ( } /> - {assigneeValue && assigneeValue.type === FilterByOptions.IUS && ( + {showAssociation && ( { const newUserIds = getSelectedViewerIds(inputValue) const selectedAssignee = getSelectorAssignee(assignee, inputValue) - setTaskViewerValue(selectedAssignee || null) - handleFieldChange('viewers', newUserIds) + setTaskAssociationValue(selectedAssignee || null) + handleFieldChange('associations', newUserIds) }} - initialValue={taskViewerValue || undefined} + initialValue={taskAssociationValue || undefined} buttonContent={ - {taskViewerValue ? : } + {taskAssociationValue ? : } (taskViewerValue ? theme.color.gray[600] : theme.color.text.textDisabled), + color: (theme) => (taskAssociationValue ? theme.color.gray[600] : theme.color.text.textDisabled), textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: '22px', @@ -453,7 +473,7 @@ export const NewTaskCard = ({ maxWidth: '120px', }} > - {getAssigneeName(taskViewerValue as IAssigneeCombined, 'Client visibility')} + {getAssigneeName(taskAssociationValue as IAssigneeCombined, 'Related to')} } @@ -462,6 +482,16 @@ export const NewTaskCard = ({ /> )} + {showShareToggle && ( + { + setIsShared(!isShared) + handleFieldChange('isShared', !isShared) + }} + checked={isShared} + /> + )} 0 && { viewers }), + ...(associations && associations.length > 0 && { associations }), + isShared, parentId, } diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index 016ea5790..a07c203af 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -12,6 +12,7 @@ import { CopilotPopSelector } from '@/components/inputs/CopilotSelector' import { DatePickerComponent } from '@/components/inputs/DatePickerComponent' import Selector, { SelectorType } from '@/components/inputs/Selector' import { WorkflowStateSelector } from '@/components/inputs/Selector-WorkflowState' +import { CustomToggle } from '@/components/inputs/CustomToggle' import { StyledTextField } from '@/components/inputs/TextField' import { MAX_UPLOAD_LIMIT } from '@/constants/attachments' import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' @@ -25,6 +26,7 @@ import { setAppliedTitle, setCreateTaskFields, setErrors, + setMultipleCreateTaskFields, } from '@/redux/features/createTaskSlice' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { selectCreateTemplate } from '@/redux/features/templateSlice' @@ -205,7 +207,7 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => store.dispatch( setCreateTaskFields({ - targetField: 'viewers', + targetField: 'associations', value: getSelectedViewerIds([{ ...taskViewerValue, object: correctedViewerObject }]), }), ) @@ -239,9 +241,17 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => const handleAssigneeChange = (inputValue: InputValue[]) => { // remove task viewers if assignee is cleared or changed to client or company - if (inputValue.length === 0 || inputValue[0].object !== UserRole.IU) { + if (inputValue.length === 0) { + store.dispatch(setCreateTaskFields({ targetField: 'isShared', value: false })) + } + if (inputValue.length && inputValue[0].object !== UserRole.IU) { setTaskViewerValue(null) - store.dispatch(setCreateTaskFields({ targetField: 'viewers', value: [] })) + store.dispatch( + setMultipleCreateTaskFields([ + { targetField: 'associations', value: [] }, + { targetField: 'isShared', value: false }, + ]), + ) } // if preview mode, auto-select current CU as viewer @@ -254,10 +264,13 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => ) ?? null, ) store.dispatch( - setCreateTaskFields({ - targetField: 'viewers', - value: [{ clientId: previewClientCompany.clientId || undefined, companyId: previewClientCompany.companyId }], - }), + setMultipleCreateTaskFields([ + { + targetField: 'associations', + value: [{ clientId: previewClientCompany.clientId || undefined, companyId: previewClientCompany.companyId }], + }, + { targetField: 'isShared', value: true }, + ]), ) } @@ -267,6 +280,11 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => store.dispatch(setCreateTaskFields({ targetField: 'userIds', value: newUserIds })) } + // client association conditions + const baseCondition = assigneeValue && assigneeValue.type === FilterByOptions.IUS + const showSharedToggle = baseCondition && taskViewerValue + const showAssociation = !assigneeValue || baseCondition + return ( } /> - {assigneeValue && assigneeValue.type === FilterByOptions.IUS && ( + {showAssociation && ( const newUserIds = getSelectedViewerIds(inputValue) const selectedTaskViewers = getSelectorAssignee(assignee, inputValue) setTaskViewerValue(selectedTaskViewers || null) - store.dispatch(setCreateTaskFields({ targetField: 'viewers', value: newUserIds })) + store.dispatch(setCreateTaskFields({ targetField: 'associations', value: newUserIds })) }} buttonContent={ maxWidth: { xs: '60px', sm: '100px' }, }} > - {getAssigneeName(taskViewerValue as IAssigneeCombined, 'Client visibility')} + {getAssigneeName(taskViewerValue as IAssigneeCombined, 'Related to')} } /> @@ -398,6 +416,24 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => )} + {showSharedToggle && ( + theme.color.background.bgCallout, + borderRadius: '4px', + }} + > + + store.dispatch( + setCreateTaskFields({ targetField: 'isShared', value: !store.getState().createTask.isShared }), + ) + } + checked={store.getState().createTask.isShared} + /> + + )} {errorMessage && ( {errorMessage} diff --git a/src/components/inputs/CustomToggle.tsx b/src/components/inputs/CustomToggle.tsx new file mode 100644 index 000000000..d41ad48f3 --- /dev/null +++ b/src/components/inputs/CustomToggle.tsx @@ -0,0 +1,15 @@ +import { Toggle } from 'copilot-design-system' + +type CustomToggleProps = { + label: string + onChange: () => void + checked: boolean +} + +export const CustomToggle = ({ label, onChange, checked }: CustomToggleProps) => { + return ( +
+ +
+ ) +} diff --git a/src/redux/features/createTaskSlice.ts b/src/redux/features/createTaskSlice.ts index 18d5e81f1..8fef507ba 100644 --- a/src/redux/features/createTaskSlice.ts +++ b/src/redux/features/createTaskSlice.ts @@ -1,7 +1,7 @@ import { RootState } from '@/redux/store' import { DateString } from '@/types/date' import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' -import { Viewers } from '@/types/dto/tasks.dto' +import { Associations } from '@/types/dto/tasks.dto' import { CreateTaskErrors, UserIds } from '@/types/interfaces' import { UserIdsType } from '@/utils/assignee' import { createSlice } from '@reduxjs/toolkit' @@ -22,11 +22,14 @@ interface IInitialState { appliedDescription: string | null templateId: string | null userIds: UserIdsType - viewers: Viewers + associations: Associations + isShared: boolean parentId: string | null disableSubtaskTemplates: boolean } +type CreateTaskFieldPayloadType = { targetField: keyof IInitialState; value: IInitialState[keyof IInitialState] } + const initialState: IInitialState = { showModal: false, activeWorkflowStateId: null, @@ -45,7 +48,8 @@ const initialState: IInitialState = { [UserIds.CLIENT_ID]: null, [UserIds.COMPANY_ID]: null, }, - viewers: [], + associations: [], + isShared: false, parentId: null, disableSubtaskTemplates: false, } @@ -68,15 +72,19 @@ const createTaskSlice = createSlice({ state.activeWorkflowStateId = action.payload }, - setCreateTaskFields: ( - state, - action: { payload: { targetField: keyof IInitialState; value: IInitialState[keyof IInitialState] } }, - ) => { + setCreateTaskFields: (state, action: { payload: CreateTaskFieldPayloadType }) => { const { targetField, value } = action.payload //@ts-ignore state[targetField] = value }, + setMultipleCreateTaskFields: (state, action: { payload: CreateTaskFieldPayloadType[] }) => { + action.payload.forEach(({ targetField, value }) => { + // @ts-ignore + state[targetField] = value + }) + }, + // sets all the fields of the create task form setAllCreateTaskFields: (state, action: { payload: CreateTaskFieldType }) => { state.title = action.payload.title @@ -101,7 +109,8 @@ const createTaskSlice = createSlice({ [UserIds.COMPANY_ID]: null, } } - state.viewers = [] + state.associations = [] + state.isShared = false state.dueDate = null state.errors = { [CreateTaskErrors.TITLE]: false, @@ -133,6 +142,7 @@ export const { setShowModal, setActiveWorkflowStateId, setCreateTaskFields, + setMultipleCreateTaskFields, clearCreateTaskFields, setErrors, setAppliedDescription, diff --git a/src/theme/theme.ts b/src/theme/theme.ts index 317226da1..da2491bda 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -69,7 +69,7 @@ export const theme = createTheme({ }, text: { text: '#212B36', - textSecondary: '#6B6F76', + textSecondary: 'var(--text-secondary)', textDisabled: '#90959D', textPlaceholder: '#9B9FA3', textPrimary: '#101828', diff --git a/src/types/dto/tasks.dto.ts b/src/types/dto/tasks.dto.ts index 8c61f3c80..2b4e6bce9 100644 --- a/src/types/dto/tasks.dto.ts +++ b/src/types/dto/tasks.dto.ts @@ -4,14 +4,14 @@ import { WorkflowStateResponseSchema } from './workflowStates.dto' import { DateStringSchema } from '@/types/date' import { ClientResponseSchema, CompanyResponseSchema, InternalUsersSchema } from '../common' -export const ViewerSchema = z.object({ +export const AssociationSchema = z.object({ clientId: z.string().uuid().optional(), companyId: z.string().uuid(), }) -export type ViewerType = z.infer +export type ViewerType = z.infer -export const ViewersSchema = z.array(ViewerSchema).max(1).optional() -export type Viewers = z.infer +export const AssociationsSchema = z.array(AssociationSchema).max(1).optional() +export type Associations = z.infer export const validateUserIds = ( data: { internalUserId?: string | null; clientId?: string | null; companyId?: string | null }, @@ -36,6 +36,34 @@ export const validateUserIds = ( } } +const validateAssociationAndTaskShare = ( + data: { + associations?: Associations + isShared?: boolean + clientId?: string | null + companyId?: string | null + internalUserId?: string | null + }, + ctx: z.RefinementCtx, +) => { + const { clientId, companyId, associations, isShared, internalUserId } = data + if (associations && associations?.length && (clientId || companyId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Task cannot have associations when assignee is client or company', + path: ['associations'], + }) + } + + if ((!associations || !associations?.length || !internalUserId) && isShared) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Task cannot be shared with no associations or when assignee is not internal user', + path: ['isShared'], + }) + } +} + export const AssigneeTypeSchema = z.nativeEnum(PrismaAssigneeType).nullish() export type AssigneeType = z.infer @@ -51,9 +79,11 @@ export const CreateTaskRequestSchema = z internalUserId: z.string().uuid().nullish().default(null), clientId: z.string().uuid().nullish().default(null), companyId: z.string().uuid().nullish().default(null), - viewers: ViewersSchema, //right now, we only need the feature to have max of 1 viewer per task + associations: AssociationsSchema, //right now, we only need the feature to have max of 1 viewer per task + isShared: z.boolean().optional(), }) .superRefine(validateUserIds) + .superRefine(validateAssociationAndTaskShare) export type CreateTaskRequest = z.infer @@ -67,9 +97,10 @@ export const UpdateTaskRequestSchema = z internalUserId: z.string().uuid().nullish(), clientId: z.string().uuid().nullish(), companyId: z.string().uuid().nullish(), - viewers: ViewersSchema, //right now, we only need the feature to have max of 1 viewer per task + associations: AssociationsSchema, //right now, we only need the feature to have max of 1 viewer per task }) .superRefine(validateUserIds) + .superRefine(validateAssociationAndTaskShare) export type UpdateTaskRequest = z.infer @@ -97,7 +128,7 @@ export const TaskResponseSchema = z.object({ internalUserId: z.string().uuid().nullish(), clientId: z.string().uuid().nullish(), companyId: z.string().uuid().nullish(), - viewers: ViewersSchema, + associations: AssociationsSchema, }) export type TaskResponse = z.infer @@ -109,7 +140,7 @@ export const SubTaskStatusSchema = z.object({ export type SubTaskStatusResponse = z.infer -export type AncestorTaskResponse = Pick & { +export type AncestorTaskResponse = Pick & { internalUserId: string | null clientId: string | null companyId: string | null diff --git a/tailwind.config.ts b/tailwind.config.ts index 8cb3ed238..3bf1c7583 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -93,7 +93,7 @@ const config: Config = { 'gray-300': '#C9CBCD', 'gray-200': '#DFE1E4', 'gray-100': '#C9CBCD', - secondary: '#6B6F76', + secondary: 'var(--text-secondary)', }, borderColor: { 'col-1': '#DFE1E4', From 817ca0e57290c7472c8b28fa2a40a385d0829372 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 6 Feb 2026 14:20:46 +0545 Subject: [PATCH 2/4] chore(OUT-3072): replace viewers by associations --- src/app/api/comments/comment.service.ts | 4 +-- .../api/notification/notification.service.ts | 12 ++++----- src/app/api/tasks/public/public.service.ts | 26 +++++++++---------- .../api/tasks/task-notifications.service.ts | 12 ++++----- src/app/api/webhook/webhook.service.ts | 4 +-- .../detail/[task_id]/[user_type]/actions.ts | 6 ++--- src/app/detail/[task_id]/[user_type]/page.tsx | 2 +- src/app/detail/ui/TaskCardList.tsx | 2 +- src/app/ui/VirtualizedTasksLists.tsx | 12 ++++----- src/hooks/useFilter.tsx | 8 +++--- src/lib/realtime.ts | 2 +- src/utils/assignee.ts | 8 +++--- src/utils/selector.ts | 8 +++--- src/utils/taskViewer.ts | 12 ++++----- 14 files changed, 60 insertions(+), 58 deletions(-) diff --git a/src/app/api/comments/comment.service.ts b/src/app/api/comments/comment.service.ts index d59ce0698..bfd59ea88 100755 --- a/src/app/api/comments/comment.service.ts +++ b/src/app/api/comments/comment.service.ts @@ -480,7 +480,7 @@ export class CommentService extends BaseService { filters.push( // Get tasks that includes the client as a viewer { - viewers: { + associations: { hasSome: [{ clientId, companyId }, { companyId }], }, }, @@ -494,7 +494,7 @@ export class CommentService extends BaseService { filters.push( // Get tasks that includes the company as a viewer { - viewers: { + associations: { hasSome: [{ companyId }], }, }, diff --git a/src/app/api/notification/notification.service.ts b/src/app/api/notification/notification.service.ts index 566126a03..ab73a4283 100644 --- a/src/app/api/notification/notification.service.ts +++ b/src/app/api/notification/notification.service.ts @@ -17,7 +17,7 @@ import { AssigneeType, ClientNotification, Task } from '@prisma/client' import Bottleneck from 'bottleneck' import httpStatus from 'http-status' import { z } from 'zod' -import { Viewers, ViewersSchema } from '@/types/dto/tasks.dto' +import { AssociationsSchema } from '@/types/dto/tasks.dto' import { getTaskViewers } from '@/utils/assignee' export class NotificationService extends BaseService { @@ -75,7 +75,7 @@ export class NotificationService extends BaseService { console.info('NotificationService#create | Created single notification:', notification) - const taskViewers = ViewersSchema.parse(task.viewers) + const taskViewers = AssociationsSchema.parse(task.associations) // 3. Save notification to ClientNotification or InternalUserNotification table. Check for notification.recipientClientId too if (task.assigneeType === AssigneeType.client && !!notification.recipientClientId && !opts.disableInProduct) { @@ -381,7 +381,7 @@ export class NotificationService extends BaseService { throw new APIError(httpStatus.NOT_FOUND, `Unknown assignee type: ${task.assigneeType}`) } } - const viewers = ViewersSchema.parse(task.viewers) + const viewers = AssociationsSchema.parse(task.associations) switch (action) { case NotificationTaskActions.Shared: @@ -550,14 +550,14 @@ export class NotificationService extends BaseService { senderCompanyId?: string, ): NotificationRequestBody { // Assume client notification then change details body if IU - const viewers = ViewersSchema.parse(task.viewers) - const viewer = viewers?.[0] + const associations = AssociationsSchema.parse(task.associations) + const association = associations?.[0] const notificationDetails: NotificationRequestBody = { senderId, senderCompanyId, senderType: this.user.role, recipientClientId: recipientId ?? undefined, - recipientCompanyId: task.companyId ?? viewer?.companyId ?? undefined, + recipientCompanyId: task.companyId ?? association?.companyId ?? undefined, // If any of the given action is not present in details obj, that type of notification is not sent deliveryTargets: deliveryTargets || {}, } diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index 1edbf3835..96952471a 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -1,7 +1,7 @@ import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' import { deleteTaskNotifications, sendTaskCreateNotifications, sendTaskUpdateNotifications } from '@/jobs/notifications' import { TaskWithWorkflowState } from '@/types/db' -import { CreateTaskRequest, CreateTaskRequestSchema, UpdateTaskRequest, Viewers, ViewersSchema } from '@/types/dto/tasks.dto' +import { CreateTaskRequest, UpdateTaskRequest, Associations, AssociationsSchema } from '@/types/dto/tasks.dto' import { DISPATCHABLE_EVENT } from '@/types/webhook' import { UserIdsType } from '@/utils/assignee' import { isPastDateString } from '@/utils/dateHelper' @@ -170,13 +170,13 @@ export class PublicTasksService extends TasksSharedService { console.info('TasksService#createTask | createdById overridden for public API:', createdById) } - let viewers: Viewers = [] - if (data.viewers?.length) { + let viewers: Associations = [] + if (data.associations?.length) { if (!validatedIds.internalUserId) { throw new APIError(httpStatus.BAD_REQUEST, `Task cannot be created with viewers if its not assigned to an IU.`) } - viewers = await this.validateViewers(data.viewers) - console.info('PublicTasksService#createTask | Viewers validated for task:', viewers) + viewers = await this.validateViewers(data.associations) + console.info('PublicTasksService#createTask | Associations validated for task:', viewers) } // Create a new task associated with current workspaceId. Also inject current request user as the creator. @@ -191,7 +191,7 @@ export class PublicTasksService extends TasksSharedService { source: Source.api, assigneeId, assigneeType, - viewers: viewers, + associations: viewers, ...validatedIds, ...(opts?.manualTimestamp && { createdAt: opts.manualTimestamp }), ...(await getTaskTimestamps('create', this.user, data, undefined, workflowStateStatus)), @@ -314,15 +314,15 @@ export class PublicTasksService extends TasksSharedService { companyId: validatedIds?.companyId ?? null, }) - let viewers: Viewers = ViewersSchema.parse(prevTask.viewers) + let associations: Associations = AssociationsSchema.parse(prevTask.associations) const viewersResetCondition = shouldUpdateUserIds ? !!clientId || !!companyId : !prevTask.internalUserId - if (data.viewers) { + if (data.associations) { // only update of viewers attribute is available. No viewers in payload attribute means the data remains as it is in DB. - if (viewersResetCondition || !data.viewers?.length) { - viewers = [] // reset viewers to [] if task is not reassigned to IU. - } else if (data.viewers?.length) { - viewers = await this.validateViewers(data.viewers) + if (viewersResetCondition || !data.associations?.length) { + associations = [] // reset viewers to [] if task is not reassigned to IU. + } else if (data.associations?.length) { + associations = await this.validateViewers(data.associations) } } @@ -371,7 +371,7 @@ export class PublicTasksService extends TasksSharedService { archivedBy, completedBy, completedByUserType, - viewers, + associations, ...userAssignmentFields, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, diff --git a/src/app/api/tasks/task-notifications.service.ts b/src/app/api/tasks/task-notifications.service.ts index a235d22a7..3a07a49c9 100644 --- a/src/app/api/tasks/task-notifications.service.ts +++ b/src/app/api/tasks/task-notifications.service.ts @@ -1,6 +1,6 @@ import { Uuid } from '@/types/common' import { TaskWithWorkflowState } from '@/types/db' -import { TaskResponseSchema, Viewers, ViewersSchema, ViewerType } from '@/types/dto/tasks.dto' +import { TaskResponseSchema, Associations, AssociationsSchema, ViewerType } from '@/types/dto/tasks.dto' import { getTaskViewers } from '@/utils/assignee' import { CopilotAPI } from '@/utils/CopilotAPI' import User from '@api/core/models/User.model' @@ -37,7 +37,7 @@ export class TaskNotificationsService extends BaseService { private async checkParentAccessible(task: TaskWithWorkflowState): Promise { if (!task.assigneeId || !task.parentId) return false - const viewers = ViewersSchema.parse(task.viewers) + const viewers = AssociationsSchema.parse(task.associations) const checkParentViewers = ( clientId: string | null, companyIds?: string[], @@ -60,11 +60,11 @@ export class TaskNotificationsService extends BaseService { if (task.assigneeType === AssigneeType.client || task.assigneeType === AssigneeType.company || !!viewers?.length) { const parentTask = await this.db.task.findFirst({ where: { id: task.parentId, workspaceId: this.user.workspaceId }, - select: { assigneeId: true, assigneeType: true, viewers: true }, + select: { assigneeId: true, assigneeType: true, associations: true }, }) if (!parentTask) return false - const parentViewer = getTaskViewers(TaskResponseSchema.pick({ viewers: true }).parse(parentTask)) + const parentViewer = getTaskViewers(TaskResponseSchema.pick({ associations: true }).parse(parentTask)) if (task.assigneeType === AssigneeType.client) { const client = await this.copilot.getClient(task.assigneeId) @@ -121,7 +121,7 @@ export class TaskNotificationsService extends BaseService { // If task is a subtask for a client/company and isn't visible on task board (is disjoint) if (await this.checkParentAccessible(task)) return - const viewers = ViewersSchema.parse(task.viewers) + const viewers = AssociationsSchema.parse(task.associations) if (viewers?.length && !isReassigned) { const clientId = viewers[0].clientId const sendViewersNotifications = clientId @@ -354,7 +354,7 @@ export class TaskNotificationsService extends BaseService { } } - private sendUserTaskSharedNotification = async (task: Task, viewers: Viewers) => { + private sendUserTaskSharedNotification = async (task: Task, viewers: Associations) => { if (!viewers?.length) return const notificationType = NotificationTaskActions.Shared diff --git a/src/app/api/webhook/webhook.service.ts b/src/app/api/webhook/webhook.service.ts index 906511667..78d8881c4 100644 --- a/src/app/api/webhook/webhook.service.ts +++ b/src/app/api/webhook/webhook.service.ts @@ -217,7 +217,7 @@ class WebhookService extends BaseService { // Find and reset tasks shared to previous company+client const prevSharedTasks = await this.db.task.findMany({ where: { - viewers: { + associations: { hasSome: [{ clientId, companyId: prevCompanyId }], }, workspaceId: this.user.workspaceId, @@ -230,7 +230,7 @@ class WebhookService extends BaseService { id: { in: prevSharedTasks.map((t) => t.id) }, }, data: { - viewers: [], + associations: [], }, }) diff --git a/src/app/detail/[task_id]/[user_type]/actions.ts b/src/app/detail/[task_id]/[user_type]/actions.ts index 66f19e9d4..085c101d1 100644 --- a/src/app/detail/[task_id]/[user_type]/actions.ts +++ b/src/app/detail/[task_id]/[user_type]/actions.ts @@ -4,7 +4,7 @@ import { advancedFeatureFlag, apiUrl } from '@/config' import { ScrapMediaRequest } from '@/types/common' import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' import { CreateComment, UpdateComment } from '@/types/dto/comment.dto' -import { UpdateTaskRequest, Viewers } from '@/types/dto/tasks.dto' +import { UpdateTaskRequest, Associations } from '@/types/dto/tasks.dto' export const updateTaskDetail = async ({ token, @@ -48,7 +48,7 @@ export const updateAssignee = async ( internalUserId: string | null, clientId: string | null, companyId: string | null, - viewers?: Viewers, + associations?: Associations, ) => { await fetch(`${apiUrl}/api/tasks/${task_id}?token=${token}`, { method: 'PATCH', @@ -56,7 +56,7 @@ export const updateAssignee = async ( internalUserId, clientId, companyId, - ...(viewers && { viewers: !internalUserId ? [] : viewers }), // if assignee is not internal user, remove viewers. Only include viewers if viewer are changed. Not including viewer means not chaning the current state of viewers in DB. + ...(associations && { associations: !internalUserId ? [] : associations }), // if assignee is not internal user, remove associations. Only include associations if viewer are changed. Not including viewer means not chaning the current state of associations in DB. }), }) } diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index aff41864b..fb1ab5e05 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -125,7 +125,7 @@ export default async function TaskDetailPage(props: { })) // flag that determines if the current user is the task viewer - const isViewer = checkIfTaskViewer(task.viewers, tokenPayload) + const isViewer = checkIfTaskViewer(task.associations, tokenPayload) return ( {isTemp || variant === 'subtask-board' || disableNavigation ? ( diff --git a/src/app/ui/VirtualizedTasksLists.tsx b/src/app/ui/VirtualizedTasksLists.tsx index 3f1f31ebf..b85411352 100644 --- a/src/app/ui/VirtualizedTasksLists.tsx +++ b/src/app/ui/VirtualizedTasksLists.tsx @@ -93,14 +93,14 @@ export function TasksRowVirtualizer({ rows, mode, token, subtasksByTaskId, workf query: { token }, }} style={{ width: 'fit-content' }} - draggable={!checkIfTaskViewer(rows[virtualRow.index].viewers, tokenPayload)} + draggable={!checkIfTaskViewer(rows[virtualRow.index].associations, tokenPayload)} > @@ -282,9 +282,9 @@ export function TasksListVirtualizer({ padding: '3px 0', width: '100%', }} - draggable={!checkIfTaskViewer(item.task.viewers, tokenPayload)} + draggable={!checkIfTaskViewer(item.task.associations, tokenPayload)} onDragStart={(e) => { - if (checkIfTaskViewer(item.task.viewers, tokenPayload)) { + if (checkIfTaskViewer(item.task.associations, tokenPayload)) { e.preventDefault() } }} @@ -294,7 +294,7 @@ export function TasksListVirtualizer({ accept={'taskCard'} index={item.taskIndex} task={item.task} - draggable={!checkIfTaskViewer(item.task.viewers, tokenPayload)} + draggable={!checkIfTaskViewer(item.task.associations, tokenPayload)} > diff --git a/src/hooks/useFilter.tsx b/src/hooks/useFilter.tsx index a1cf66531..8713041d0 100644 --- a/src/hooks/useFilter.tsx +++ b/src/hooks/useFilter.tsx @@ -60,10 +60,12 @@ function filterByClientVisibility(filteredTasks: TaskResponse[], filterValue: Us if (clientId) { filteredTasks = filteredTasks.filter( - (task) => task.viewers?.[0]?.clientId === clientId && task.viewers?.[0]?.companyId === companyId, + (task) => task.associations?.[0]?.clientId === clientId && task.associations?.[0]?.companyId === companyId, ) } else if (companyId && !clientId) { - filteredTasks = filteredTasks.filter((task) => task.viewers?.[0]?.companyId === companyId && !task.viewers?.[0].clientId) + filteredTasks = filteredTasks.filter( + (task) => task.associations?.[0]?.companyId === companyId && !task.associations?.[0].clientId, + ) } return filteredTasks @@ -129,7 +131,7 @@ function filterByType(filteredTasks: TaskResponse[], filterValue: string): TaskR case FilterOptionsKeywords.CLIENT_WITH_VIEWERS: return filteredTasks.filter( (task) => - !!task?.viewers?.length || task?.assigneeType?.includes('client') || task?.assigneeType?.includes('company'), + !!task?.associations?.length || task?.assigneeType?.includes('client') || task?.assigneeType?.includes('company'), ) case FilterOptionsKeywords.TEAM: diff --git a/src/lib/realtime.ts b/src/lib/realtime.ts index a4c0123a5..acb1be0da 100644 --- a/src/lib/realtime.ts +++ b/src/lib/realtime.ts @@ -30,7 +30,7 @@ export class RealtimeHandler { private isViewer(newTask: RealTimeTaskResponse): boolean { return this.tokenPayload.clientId || !!getPreviewMode(this.tokenPayload) - ? (newTask.viewers?.some( + ? (newTask.associations?.some( (viewer) => (viewer.clientId === this.tokenPayload.clientId && viewer.companyId === this.tokenPayload.companyId) || (!viewer.clientId && viewer.companyId === this.tokenPayload.companyId), diff --git a/src/utils/assignee.ts b/src/utils/assignee.ts index 98d8943af..b066ff971 100644 --- a/src/utils/assignee.ts +++ b/src/utils/assignee.ts @@ -1,6 +1,6 @@ import { Token } from '@/types/common' import { TruncateMaxNumber } from '@/types/constants' -import { TaskResponse, Viewers, ViewersSchema } from '@/types/dto/tasks.dto' +import { TaskResponse, Associations, AssociationsSchema } from '@/types/dto/tasks.dto' import { IAssigneeCombined, ISelectorOption, UserIds, UserType } from '@/types/interfaces' import { getAssigneeTypeCorrected } from '@/utils/getAssigneeTypeCorrected' import { truncateText } from '@/utils/truncateText' @@ -17,7 +17,7 @@ export const UserIdsSchema = z.object({ export type UserIdsType = z.infer -export type UserIdsWithViewersType = UserIdsType & { viewers?: Viewers } +export type UserIdsWithViewersType = UserIdsType & { viewers?: Associations } export const isAssigneeTextMatching = (newInputValue: string, assigneeValue: IAssigneeCombined): boolean => { const truncate = (newInputValue: string) => truncateText(newInputValue, TruncateMaxNumber.SELECTOR) @@ -103,8 +103,8 @@ export const getAssigneeValueFromViewers = (viewer: IAssigneeCombined | null, as return match ?? undefined } -export const getTaskViewers = (task: TaskResponse | Task | Pick) => { - const taskViewers = ViewersSchema.parse(task.viewers) +export const getTaskViewers = (task: TaskResponse | Task | Pick) => { + const taskViewers = AssociationsSchema.parse(task.associations) const viewer = !!taskViewers?.length ? taskViewers[0] : undefined return viewer } diff --git a/src/utils/selector.ts b/src/utils/selector.ts index 82a1d5db2..3dadda3a7 100644 --- a/src/utils/selector.ts +++ b/src/utils/selector.ts @@ -5,7 +5,7 @@ import { IAssigneeCombined, InputValue, ISelectorOption, UserIds } from '@/types/interfaces' import { userIdFieldMap } from '@/types/objectMaps' import { UserIdsType } from './assignee' -import { TaskResponse, Viewers } from '@/types/dto/tasks.dto' +import { TaskResponse, Associations } from '@/types/dto/tasks.dto' import { UserRole } from '@/app/api/core/types/user' import { z } from 'zod' @@ -62,8 +62,8 @@ export const getSelectorViewerFromTask = (assignee: IAssigneeCombined[], task: T if (!task) return undefined return assignee.find( (assignee) => - (task.viewers?.[0]?.clientId == assignee.id && task.viewers?.[0]?.companyId == assignee.companyId) || - task.viewers?.[0]?.companyId == assignee.id, + (task.associations?.[0]?.clientId == assignee.id && task.associations?.[0]?.companyId == assignee.companyId) || + task.associations?.[0]?.companyId == assignee.id, ) } @@ -81,7 +81,7 @@ export const getSelectorAssigneeFromFilterOptions = ( ) } //util to get initial assignee from filterOptions for selector. -export const getSelectedViewerIds = (inputValue: InputValue[]): Viewers => { +export const getSelectedViewerIds = (inputValue: InputValue[]): Associations => { if (!inputValue?.length || inputValue[0].object === UserRole.IU) return [] // when no user is selected. if (inputValue[0].object === UserRole.Client) diff --git a/src/utils/taskViewer.ts b/src/utils/taskViewer.ts index 656f3f812..2be1bbdd5 100644 --- a/src/utils/taskViewer.ts +++ b/src/utils/taskViewer.ts @@ -1,13 +1,13 @@ import { Token } from '@/types/common' -import { Viewers } from '@/types/dto/tasks.dto' +import { Associations } from '@/types/dto/tasks.dto' import { getPreviewMode } from './previewMode' -export const checkIfTaskViewer = (viewers: Viewers, tokenPayload: Token | undefined): boolean => { +export const checkIfTaskViewer = (associations: Associations, tokenPayload: Token | undefined): boolean => { return ( - Array.isArray(viewers) && - viewers.length > 0 && - (!viewers[0].clientId || viewers[0].clientId === tokenPayload?.clientId) && - viewers[0].companyId === tokenPayload?.companyId && + Array.isArray(associations) && + associations.length > 0 && + (!associations[0].clientId || associations[0].clientId === tokenPayload?.clientId) && + associations[0].companyId === tokenPayload?.companyId && !getPreviewMode(tokenPayload) ) } From c5821306d7f0e5f5966d9f5702e30c1a154bc2d7 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 6 Feb 2026 14:33:34 +0545 Subject: [PATCH 3/4] fix(OUT-3072): omit associations and isShared attributes --- src/cmd/load-testing/load-testing.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cmd/load-testing/load-testing.service.ts b/src/cmd/load-testing/load-testing.service.ts index f9114a3ef..80373385e 100644 --- a/src/cmd/load-testing/load-testing.service.ts +++ b/src/cmd/load-testing/load-testing.service.ts @@ -108,7 +108,8 @@ class LoadTester { | 'clientId' | 'companyId' | 'lastSubtaskUpdated' - | 'viewers' + | 'associations' + | 'isShared' >[] = [] const currentUser = await authenticateWithToken(this.token, this.apiKey) const labelsService = new LabelMappingService(currentUser, this.apiKey) From 76779c97269960cd263e3558f162826827f999c8 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 6 Feb 2026 16:04:12 +0545 Subject: [PATCH 4/4] refactor(OUT-3072): use appropriate component name, break function to smaller functions --- src/app/api/tasks/public/public.service.ts | 35 ++++++++++++------- src/app/detail/ui/NewTaskCard.tsx | 4 +-- src/app/globals.css | 2 +- src/app/ui/NewTaskForm.tsx | 4 +-- .../{CustomToggle.tsx => CopilotToggle.tsx} | 6 ++-- 5 files changed, 31 insertions(+), 20 deletions(-) rename src/components/inputs/{CustomToggle.tsx => CopilotToggle.tsx} (56%) diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index 96952471a..302a28c05 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -1,6 +1,5 @@ import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' import { deleteTaskNotifications, sendTaskCreateNotifications, sendTaskUpdateNotifications } from '@/jobs/notifications' -import { TaskWithWorkflowState } from '@/types/db' import { CreateTaskRequest, UpdateTaskRequest, Associations, AssociationsSchema } from '@/types/dto/tasks.dto' import { DISPATCHABLE_EVENT } from '@/types/webhook' import { UserIdsType } from '@/utils/assignee' @@ -279,6 +278,25 @@ export class PublicTasksService extends TasksSharedService { return newTask } + private async getValidatedAssociations({ + prevAssociations, + associationsResetCondition, + }: { + prevAssociations: Prisma.JsonValue[] + associationsResetCondition: boolean + }) { + let associations: Associations = AssociationsSchema.parse(prevAssociations) + if (associations) { + // only update of associations attribute is available. No associations in payload attribute means the data remains as it is in DB. + if (associationsResetCondition || !associations?.length) { + associations = [] // reset associations to [] if task is not reassigned to IU. + } else if (associations?.length) { + associations = await this.validateViewers(associations) + } + } + return associations + } + async updateTask(id: string, data: UpdateTaskRequest) { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Update, Resource.Tasks) @@ -314,17 +332,10 @@ export class PublicTasksService extends TasksSharedService { companyId: validatedIds?.companyId ?? null, }) - let associations: Associations = AssociationsSchema.parse(prevTask.associations) - - const viewersResetCondition = shouldUpdateUserIds ? !!clientId || !!companyId : !prevTask.internalUserId - if (data.associations) { - // only update of viewers attribute is available. No viewers in payload attribute means the data remains as it is in DB. - if (viewersResetCondition || !data.associations?.length) { - associations = [] // reset viewers to [] if task is not reassigned to IU. - } else if (data.associations?.length) { - associations = await this.validateViewers(data.associations) - } - } + const associations: Associations = await this.getValidatedAssociations({ + prevAssociations: prevTask.associations, + associationsResetCondition: shouldUpdateUserIds ? !!clientId || !!companyId : !prevTask.internalUserId, + }) const userAssignmentFields = shouldUpdateUserIds ? { diff --git a/src/app/detail/ui/NewTaskCard.tsx b/src/app/detail/ui/NewTaskCard.tsx index 0349af20e..fabfeb472 100644 --- a/src/app/detail/ui/NewTaskCard.tsx +++ b/src/app/detail/ui/NewTaskCard.tsx @@ -9,7 +9,7 @@ import { CopilotPopSelector } from '@/components/inputs/CopilotSelector' import { DatePickerComponent } from '@/components/inputs/DatePickerComponent' import Selector, { SelectorType } from '@/components/inputs/Selector' import { WorkflowStateSelector } from '@/components/inputs/Selector-WorkflowState' -import { CustomToggle } from '@/components/inputs/CustomToggle' +import { CopilotToggle } from '@/components/inputs/CopilotToggle' import { StyledTextField } from '@/components/inputs/TextField' import { MAX_UPLOAD_LIMIT } from '@/constants/attachments' import { useHandleSelectorComponent } from '@/hooks/useHandleSelectorComponent' @@ -483,7 +483,7 @@ export const NewTaskCard = ({ )} {showShareToggle && ( - { setIsShared(!isShared) diff --git a/src/app/globals.css b/src/app/globals.css index dbf0b9a54..3720cc2ce 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -97,7 +97,7 @@ a { } /* Custom toggle wrapper to override design system styles */ -.custom-toggle-wrapper { +.copilot-toggle-wrapper { padding: 8px 6px 8px; & .cop-font-medium { font-weight: 400; diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index a07c203af..a9c0807f3 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -12,7 +12,7 @@ import { CopilotPopSelector } from '@/components/inputs/CopilotSelector' import { DatePickerComponent } from '@/components/inputs/DatePickerComponent' import Selector, { SelectorType } from '@/components/inputs/Selector' import { WorkflowStateSelector } from '@/components/inputs/Selector-WorkflowState' -import { CustomToggle } from '@/components/inputs/CustomToggle' +import { CopilotToggle } from '@/components/inputs/CopilotToggle' import { StyledTextField } from '@/components/inputs/TextField' import { MAX_UPLOAD_LIMIT } from '@/constants/attachments' import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' @@ -423,7 +423,7 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => borderRadius: '4px', }} > - store.dispatch( diff --git a/src/components/inputs/CustomToggle.tsx b/src/components/inputs/CopilotToggle.tsx similarity index 56% rename from src/components/inputs/CustomToggle.tsx rename to src/components/inputs/CopilotToggle.tsx index d41ad48f3..5a4fbc41b 100644 --- a/src/components/inputs/CustomToggle.tsx +++ b/src/components/inputs/CopilotToggle.tsx @@ -1,14 +1,14 @@ import { Toggle } from 'copilot-design-system' -type CustomToggleProps = { +type CopilotToggleProps = { label: string onChange: () => void checked: boolean } -export const CustomToggle = ({ label, onChange, checked }: CustomToggleProps) => { +export const CopilotToggle = ({ label, onChange, checked }: CopilotToggleProps) => { return ( -
+
)