diff --git a/package.json b/package.json index 05e3c500b..594481b66 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@assembly-js/node-sdk": "^3.19.1", "@cyntler/react-doc-viewer": "^1.17.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", @@ -70,6 +71,7 @@ "nodemon": "^3.1.9", "open": "^10.1.0", "prettier": "^3.1.1", + "tailwind-merge": "^3.4.0", "tailwindcss": "^3.3.0", "text-table": "^0.2.0", "tsx": "^4.16.5", 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/sentry.client.config.ts b/sentry.client.config.ts index 5260484fc..8e0fc39e3 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -2,55 +2,55 @@ // The config you add here will be used whenever a users loads a page in their browser. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ -import * as Sentry from "@sentry/nextjs"; +import * as Sentry from '@sentry/nextjs' -const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN; -const vercelEnv = process.env.NEXT_PUBLIC_VERCEL_ENV; -const isProd = process.env.NEXT_PUBLIC_VERCEL_ENV === "production"; +const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN +const vercelEnv = process.env.NEXT_PUBLIC_VERCEL_ENV +const isProd = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' if (dsn) { - Sentry.init({ - dsn, - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: isProd ? 0.2 : 1, - profilesSampleRate: 0.1, - // NOTE: reducing sample only 10% of transactions in prod to get general trends instead of detailed and overfitted data - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - // You can remove this option if you're not planning to use the Sentry Session Replay feature: - // NOTE: Since session replay barely helps us anyways, getting rid of it to reduce some bundle size at least - // replaysOnErrorSampleRate: 1.0, - // replaysSessionSampleRate: 0, - integrations: [ - Sentry.browserTracingIntegration({ - beforeStartSpan: (e) => { - console.info('SentryBrowserTracingSpan', e.name) - return e - }, - }), - // Sentry.replayIntegration({ - // Additional Replay configuration goes in here, for example: - // maskAllText: true, - // blockAllMedia: true, - // }), - ], - - // ignoreErrors: [/fetch failed/i], - ignoreErrors: [/fetch failed/i], - - beforeSend(event) { - if (!isProd && event.type === undefined) { - return null - } - event.tags = { - ...event.tags, - // Adding additional app_env tag for cross-checking - app_env: isProd ? 'production' : vercelEnv || 'development', - } - return event - }, - }) + Sentry.init({ + dsn, + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: isProd ? 0.2 : 1, + profilesSampleRate: 0.1, + // NOTE: reducing sample only 10% of transactions in prod to get general trends instead of detailed and overfitted data + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + // You can remove this option if you're not planning to use the Sentry Session Replay feature: + // NOTE: Since session replay barely helps us anyways, getting rid of it to reduce some bundle size at least + // replaysOnErrorSampleRate: 1.0, + // replaysSessionSampleRate: 0, + integrations: [ + Sentry.browserTracingIntegration({ + beforeStartSpan: (e) => { + console.info('SentryBrowserTracingSpan', e.name) + return e + }, + }), + // Sentry.replayIntegration({ + // Additional Replay configuration goes in here, for example: + // maskAllText: true, + // blockAllMedia: true, + // }), + ], + + // ignoreErrors: [/fetch failed/i], + ignoreErrors: [/fetch failed/i], + + beforeSend(event) { + if (!isProd && event.type === undefined) { + return null + } + event.tags = { + ...event.tags, + // Adding additional app_env tag for cross-checking + app_env: isProd ? 'production' : vercelEnv || 'development', + } + return event + }, + }) } diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 8c6accff6..517962e0e 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -2,31 +2,31 @@ // The config you add here will be used whenever the server handles a request. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ -import * as Sentry from "@sentry/nextjs"; +import * as Sentry from '@sentry/nextjs' const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN const vercelEnv = process.env.NEXT_PUBLIC_VERCEL_ENV const isProd = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' if (dsn) { - Sentry.init({ - dsn, + Sentry.init({ + dsn, - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, - // Uncomment the line below to enable Spotlight (https://spotlightjs.com) - // spotlight: process.env.NODE_ENV === 'development', - ignoreErrors: [/fetch failed/i], + // Uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: process.env.NODE_ENV === 'development', + ignoreErrors: [/fetch failed/i], - beforeSend(event) { - if (!isProd && event.type === undefined) { - return null - } - return event - }, - }) + beforeSend(event) { + if (!isProd && event.type === undefined) { + return null + } + return event + }, + }) } diff --git a/src/app/api/comments/comment.service.ts b/src/app/api/comments/comment.service.ts index d59ce0698..95ae9ea94 100755 --- a/src/app/api/comments/comment.service.ts +++ b/src/app/api/comments/comment.service.ts @@ -2,7 +2,7 @@ import { AttachmentsService } from '@/app/api/attachments/attachments.service' import { PublicCommentSerializer } from '@/app/api/comments/public/public.serializer' import { sendCommentCreateNotifications } from '@/jobs/notifications' import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' -import { InitiatedEntity } from '@/types/common' +import { InitiatedEntity, TempClientFilter } from '@/types/common' import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' import { CommentsPublicFilterType, CommentWithAttachments, CreateComment, UpdateComment } from '@/types/dto/comment.dto' import { DISPATCHABLE_EVENT } from '@/types/webhook' @@ -463,9 +463,10 @@ export class CommentService extends BaseService { } } - protected getClientOrCompanyAssigneeFilter(includeViewer: boolean = true): Prisma.TaskWhereInput { + protected getClientOrCompanyAssigneeFilter(includeAssociatedTask: boolean = true): Prisma.TaskWhereInput { const clientId = z.string().uuid().parse(this.user.clientId) const companyId = z.string().uuid().parse(this.user.companyId) + const isCuPortal = !this.user.internalUserId && (clientId || companyId) const filters = [] @@ -476,29 +477,32 @@ export class CommentService extends BaseService { // Get company tasks for the client's companyId { companyId, clientId: null }, ) - if (includeViewer) - filters.push( - // Get tasks that includes the client as a viewer - { - viewers: { - hasSome: [{ clientId, companyId }, { companyId }], - }, + if (includeAssociatedTask) { + const tempClientFilter: TempClientFilter = { + associations: { + hasSome: [{ clientId, companyId }, { companyId }], }, - ) + } + if (isCuPortal) tempClientFilter.isShared = true + // Get tasks that includes the client as a association + filters.push(tempClientFilter) + } } else if (companyId) { filters.push( // Get only company tasks for the client's companyId { clientId: null, companyId }, ) - if (includeViewer) - filters.push( - // Get tasks that includes the company as a viewer - { - viewers: { - hasSome: [{ companyId }], - }, + + // Get tasks that includes the company as a association + if (includeAssociatedTask) { + const tempCompanyFilter: TempClientFilter = { + associations: { + hasSome: [{ companyId }], }, - ) + } + if (isCuPortal) tempCompanyFilter.isShared = true + filters.push(tempCompanyFilter) + } } return filters.length > 0 ? { OR: filters } : {} } //Repeated twice because taskSharedService is an abstract class. diff --git a/src/app/api/notification/notification.service.ts b/src/app/api/notification/notification.service.ts index 566126a03..f9ee3bf89 100644 --- a/src/app/api/notification/notification.service.ts +++ b/src/app/api/notification/notification.service.ts @@ -17,8 +17,8 @@ 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 { getTaskViewers } from '@/utils/assignee' +import { AssociationsSchema } from '@/types/dto/tasks.dto' +import { getTaskAssociations } from '@/utils/assignee' export class NotificationService extends BaseService { async create( @@ -75,8 +75,6 @@ export class NotificationService extends BaseService { console.info('NotificationService#create | Created single notification:', notification) - const taskViewers = ViewersSchema.parse(task.viewers) - // 3. Save notification to ClientNotification or InternalUserNotification table. Check for notification.recipientClientId too if (task.assigneeType === AssigneeType.client && !!notification.recipientClientId && !opts.disableInProduct) { await this.addToClientNotifications(task, NotificationCreatedResponseSchema.parse(notification)) @@ -280,14 +278,14 @@ export class NotificationService extends BaseService { */ markClientNotificationAsRead = async (task: Task) => { try { - const taskViewer = getTaskViewers(task) + const taskAssociations = getTaskAssociations(task) // Due to race conditions, we are forced to allow multiple client notifications for a single notification as well const relatedNotifications = await this.db.clientNotification.findMany({ where: { // Accomodate company task lookups where clientId is null - clientId: Uuid.nullable().parse(task.clientId) || taskViewer?.clientId, - companyId: Uuid.parse(task.companyId ?? taskViewer?.companyId), + clientId: Uuid.nullable().parse(task.clientId) || taskAssociations?.clientId, + companyId: Uuid.parse(task.companyId ?? taskAssociations?.companyId), taskId: task.id, }, }) @@ -381,18 +379,18 @@ export class NotificationService extends BaseService { throw new APIError(httpStatus.NOT_FOUND, `Unknown assignee type: ${task.assigneeType}`) } } - const viewers = ViewersSchema.parse(task.viewers) + const associations = AssociationsSchema.parse(task.associations) switch (action) { case NotificationTaskActions.Shared: senderId = task.createdById - recipientId = !!viewers?.length ? z.string().parse(viewers[0].clientId) : '' + recipientId = !!associations?.length ? z.string().parse(associations[0].clientId) : '' actionTrigger = await this.copilot.getInternalUser(senderId) break case NotificationTaskActions.SharedToCompany: senderId = task.createdById - recipientIds = !!viewers?.length - ? (await this.copilot.getCompanyClients(z.string().parse(viewers[0].companyId))).map((client) => client.id) + recipientIds = !!associations?.length + ? (await this.copilot.getCompanyClients(z.string().parse(associations[0].companyId))).map((client) => client.id) : [] actionTrigger = await this.copilot.getInternalUser(senderId) break @@ -450,17 +448,17 @@ export class NotificationService extends BaseService { console.info('fetched client Ids', clientIds) recipientIds = clientIds } - if (viewers?.length) { - const clientId = viewers[0].clientId + if (associations?.length) { + const clientId = associations[0].clientId if (clientId) { - recipientIds = [clientId] //spread recipientIds if we allow viewers on client tasks. + recipientIds = [clientId] //spread recipientIds if we allow associations on client tasks. } else { - const clientsInCompany = await this.copilot.getCompanyClients(viewers[0].companyId) + const clientsInCompany = await this.copilot.getCompanyClients(associations[0].companyId) const clientIds = clientsInCompany.map((client) => client.id) console.info('fetched client Ids', clientIds) recipientIds = clientIds } - } //viewers comment notifications + } //associations comment notifications // this break is needed otherwise we will fallthrough to the IU case. // This is honestly unhinged JS behavior, I would not expect the // next case to run if the switch did not match it @@ -550,14 +548,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.dto.ts b/src/app/api/tasks/public/public.dto.ts index e909f25a8..ff7ef956f 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,8 +41,10 @@ export const PublicTaskDtoSchema = z.object({ internalUserId: z.string().uuid().nullable(), clientId: z.string().uuid().nullable(), companyId: z.string().uuid().nullable(), - viewers: ViewersSchema, + association: AssociationsSchema, + viewers: AssociationsSchema, attachments: z.array(PublicAttachmentDtoSchema), + isShared: z.boolean().optional(), }) export type PublicTaskDto = z.infer @@ -59,7 +61,8 @@ 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 + association: AssociationsSchema, //right now, we only need the feature to have max of 1 viewer per task + isShared: z.boolean().optional(), }) .superRefine(async (data, ctx) => { const { name, templateId, internalUserId, clientId, status } = data @@ -116,14 +119,6 @@ export const publicTaskCreateDtoSchemaFactory = (token: string) => { } } - if (!internalUserId && !clientId && !companyId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'At least one of internalUserId, clientId, or companyId is required', - path: ['internalUserId'], - }) - } - if (internalUserId && (clientId || companyId)) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -153,7 +148,8 @@ export const PublicTaskUpdateDtoSchema = z internalUserId: z.string().uuid().nullish(), clientId: z.string().uuid().nullish(), companyId: z.string().uuid().nullish(), - viewers: ViewersSchema, + association: AssociationsSchema, + isShared: z.boolean().optional(), }) .superRefine(validateUserIds) diff --git a/src/app/api/tasks/public/public.serializer.ts b/src/app/api/tasks/public/public.serializer.ts index 1279802e7..6de8e1d0d 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,12 +63,14 @@ export class PublicTaskSerializer { internalUserId: task.internalUserId, clientId: task.clientId, companyId: task.companyId, - viewers: ViewersSchema.parse(task.viewers), + association: AssociationsSchema.parse(task.associations), + viewers: task.isShared ? AssociationsSchema.parse(task.associations) : [], attachments: await PublicAttachmentSerializer.serializeAttachments({ attachments: task.attachments, uploadedByUserType: 'internalUser', // task creator is always IU content: task.body, }), + isShared: task.isShared, } } @@ -153,7 +155,8 @@ export class PublicTaskSerializer { internalUserId: payload.internalUserId ?? null, clientId: payload.clientId ?? null, companyId: payload.companyId ?? null, - viewers: payload.viewers ?? [], + associations: payload.association ?? [], + isShared: payload.isShared, }) } @@ -168,7 +171,8 @@ export class PublicTaskSerializer { internalUserId: payload.internalUserId, clientId: payload.clientId, companyId: payload.companyId, - viewers: payload.viewers, + associations: payload.association, + isShared: payload.isShared, }) } } diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index 1edbf3835..18bca794c 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -1,7 +1,6 @@ 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 +169,13 @@ export class PublicTasksService extends TasksSharedService { console.info('TasksService#createTask | createdById overridden for public API:', createdById) } - let viewers: Viewers = [] - if (data.viewers?.length) { - if (!validatedIds.internalUserId) { + let associations: Associations = [] + if (data.associations?.length) { + if (!!data.isShared && !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) + associations = await this.validateAssociations(data.associations) + console.info('PublicTasksService#createTask | Associations validated for task:', associations) } // Create a new task associated with current workspaceId. Also inject current request user as the creator. @@ -191,7 +190,7 @@ export class PublicTasksService extends TasksSharedService { source: Source.api, assigneeId, assigneeType, - viewers: viewers, + associations, ...validatedIds, ...(opts?.manualTimestamp && { createdAt: opts.manualTimestamp }), ...(await getTaskTimestamps('create', this.user, data, undefined, workflowStateStatus)), @@ -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.validateAssociations(associations) + } + } + return associations + } + async updateTask(id: string, data: UpdateTaskRequest) { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Update, Resource.Tasks) @@ -314,17 +332,13 @@ export class PublicTasksService extends TasksSharedService { companyId: validatedIds?.companyId ?? null, }) - let viewers: Viewers = ViewersSchema.parse(prevTask.viewers) - - 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) - } - } + const associations = await this.resolveAssociations({ + prevTask, + data, + shouldUpdateUserIds, + clientId, + companyId, + }) const userAssignmentFields = shouldUpdateUserIds ? { @@ -371,7 +385,8 @@ export class PublicTasksService extends TasksSharedService { archivedBy, completedBy, completedByUserType, - viewers, + associations, + isShared: this.validateTaskShare(prevTask, data), ...userAssignmentFields, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, diff --git a/src/app/api/tasks/subtasks.service.ts b/src/app/api/tasks/subtasks.service.ts index af9429f83..a2a540a92 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,8 @@ interface Assignable { internalUserId: string | null clientId: string | null companyId: string | null - viewers: JsonValue[] + associations: JsonValue[] + isShared: boolean } export class SubtaskService extends BaseService { @@ -127,8 +128,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 !( @@ -136,7 +137,8 @@ export class SubtaskService extends BaseService { (task.clientId === null && task.companyId === this.user.companyId) || (viewer && (!viewer?.clientId || viewer?.clientId === this.user.clientId) && - viewer.companyId === this.user.companyId) + viewer.companyId === this.user.companyId && + task.isShared) ) }) } else { diff --git a/src/app/api/tasks/task-notifications.service.ts b/src/app/api/tasks/task-notifications.service.ts index a235d22a7..e8b84a971 100644 --- a/src/app/api/tasks/task-notifications.service.ts +++ b/src/app/api/tasks/task-notifications.service.ts @@ -1,7 +1,7 @@ import { Uuid } from '@/types/common' import { TaskWithWorkflowState } from '@/types/db' -import { TaskResponseSchema, Viewers, ViewersSchema, ViewerType } from '@/types/dto/tasks.dto' -import { getTaskViewers } from '@/utils/assignee' +import { TaskResponseSchema, Associations, AssociationsSchema, ViewerType } from '@/types/dto/tasks.dto' +import { getTaskAssociations } from '@/utils/assignee' import { CopilotAPI } from '@/utils/CopilotAPI' import User from '@api/core/models/User.model' import { BaseService } from '@api/core/services/base.service' @@ -37,8 +37,8 @@ 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 checkParentViewers = ( + const associations = AssociationsSchema.parse(task.associations) + const checkParentAssociations = ( clientId: string | null, companyIds?: string[], parentViewer?: ViewerType, @@ -49,7 +49,7 @@ export class TaskNotificationsService extends BaseService { if ( (parentViewerClientId === clientId && companyIds?.includes(parentViewerCompanyId)) || //check if parent's client assignee is same as the viewer of child task. (parentViewerClientId && clientIds?.includes(parentViewerClientId)) || //case when child is assigned or shared to a company and parent contains client list of the company. - (parentViewerClientId === null && companyIds?.includes(parentViewerCompanyId)) //case when child is assigned or shared to a company and parent contains companyId in viewers of the clients. + (parentViewerClientId === null && companyIds?.includes(parentViewerCompanyId)) //case when child is assigned or shared to a company and parent contains companyId in association of the clients. ) { return true } @@ -57,21 +57,21 @@ export class TaskNotificationsService extends BaseService { return false } - if (task.assigneeType === AssigneeType.client || task.assigneeType === AssigneeType.company || !!viewers?.length) { + if (task.assigneeType === AssigneeType.client || task.assigneeType === AssigneeType.company || !!associations?.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 = getTaskAssociations(TaskResponseSchema.pick({ associations: true }).parse(parentTask)) if (task.assigneeType === AssigneeType.client) { const client = await this.copilot.getClient(task.assigneeId) if (parentTask.assigneeId === client.id || parentTask.assigneeId === client.companyId) { return true } - if (checkParentViewers(client.id, client.companyIds, parentViewer)) { + if (checkParentAssociations(client.id, client.companyIds, parentViewer)) { return true } } else { @@ -81,32 +81,32 @@ export class TaskNotificationsService extends BaseService { if (companyClientIds.includes(parentTask.assigneeId || '__empty__')) { return true } - if (checkParentViewers(null, [task.assigneeId], parentViewer, companyClientIds)) { + if (checkParentAssociations(null, [task.assigneeId], parentViewer, companyClientIds)) { return true } } //for assignment notifications - if (!!viewers?.length) { - if (viewers[0].clientId) { - const client = await this.copilot.getClient(viewers[0].clientId) + if (!!associations?.length) { + if (associations[0].clientId) { + const client = await this.copilot.getClient(associations[0].clientId) if (parentTask.assigneeId === client.id || parentTask.assigneeId === client.companyId) { return true } - if (checkParentViewers(client.id, client.companyIds, parentViewer)) { + if (checkParentAssociations(client.id, client.companyIds, parentViewer)) { return true } } else { const clients = await this.copilot.getClients() const companyClientIds = - clients.data?.filter((client) => client.companyId === viewers[0].companyId).map((client) => client.id) || [] + clients.data?.filter((client) => client.companyId === associations[0].companyId).map((client) => client.id) || [] if (companyClientIds.includes(parentTask.assigneeId || '__empty__')) { return true } - if (checkParentViewers(null, [viewers[0].companyId], parentViewer, companyClientIds)) { + if (checkParentAssociations(null, [associations[0].companyId], parentViewer, companyClientIds)) { return true } } - } //for viewers notifications + } //for task shared notifications } return false } @@ -121,13 +121,12 @@ 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) - if (viewers?.length && !isReassigned) { - const clientId = viewers[0].clientId - const sendViewersNotifications = clientId - ? this.sendUserTaskSharedNotification - : this.sendCompanyTaskSharedNotification - await sendViewersNotifications(task, viewers) + const associations = AssociationsSchema.parse(task.associations) + const isShared = task.isShared + if (associations?.length && !isReassigned && isShared) { + const clientId = associations[0].clientId + const sendSharedNotifications = clientId ? this.sendUserTaskSharedNotification : this.sendCompanyTaskSharedNotification + await sendSharedNotifications(task, associations) } // If task is assigned to the same person that created it, no need to notify yourself @@ -156,26 +155,26 @@ export class TaskNotificationsService extends BaseService { if (prevTask.isArchived !== updatedTask.isArchived) { await this.handleTaskArchiveToggle(prevTask, updatedTask) } - const updatedViewers = getTaskViewers(updatedTask) - const prevViewers = getTaskViewers(prevTask) - - const isViewersUpdated = - !!updatedViewers && - ((!!updatedViewers.clientId && prevViewers?.clientId !== updatedViewers?.clientId) || - prevViewers?.companyId !== updatedViewers.companyId) + const updatedAssociations = getTaskAssociations(updatedTask) + const prevAssociations = getTaskAssociations(prevTask) + const becameShared = !prevTask.isShared && updatedTask.isShared + const isAssociationsUpdated = + !!updatedAssociations && + ((!!updatedAssociations.clientId && prevAssociations?.clientId !== updatedAssociations?.clientId) || + prevAssociations?.companyId !== updatedAssociations.companyId) // Return if not workflowState / assignee updated const isReassigned = prevTask.assigneeId !== updatedTask.assigneeId - if (prevTask.workflowStateId === updatedTask.workflowStateId && !isReassigned && !isViewersUpdated) return + if (prevTask.workflowStateId === updatedTask.workflowStateId && !isReassigned && !isAssociationsUpdated && !becameShared) + return // Case 2 - // -Handle viewers changed, or viewers updated in a task. - if (isViewersUpdated) { - const clientId = updatedViewers.clientId - const sendViewersNotifications = clientId - ? this.sendUserTaskSharedNotification - : this.sendCompanyTaskSharedNotification - await sendViewersNotifications(updatedTask, [updatedViewers]) + // -Handle associations changed, or associations updated in a task when task is shared. + // if the task became shared, or task the association changed in a shared task condition: + if (updatedAssociations && (becameShared || (updatedTask.isShared && isAssociationsUpdated))) { + const clientId = updatedAssociations.clientId + const sendSharedNotifications = clientId ? this.sendUserTaskSharedNotification : this.sendCompanyTaskSharedNotification + await sendSharedNotifications(updatedTask, [updatedAssociations]) } // Case 3 @@ -354,15 +353,15 @@ export class TaskNotificationsService extends BaseService { } } - private sendUserTaskSharedNotification = async (task: Task, viewers: Viewers) => { - if (!viewers?.length) return + private sendUserTaskSharedNotification = async (task: Task, associations: Associations) => { + if (!associations?.length) return const notificationType = NotificationTaskActions.Shared const existingNotification = await this.getExistingClientNotificationForTask( task.id, - Uuid.parse(viewers[0].clientId), - Uuid.parse(viewers[0].companyId), + Uuid.parse(associations[0].clientId), + Uuid.parse(associations[0].companyId), ) if (existingNotification) { console.error('Found an existing notification. Skipping creating a new one:', existingNotification) diff --git a/src/app/api/tasks/tasks.logger.ts b/src/app/api/tasks/tasks.logger.ts index e836ec76f..379df3670 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,23 +55,34 @@ 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 currentAssociations = AssociationsSchema.parse(this.task.associations) || [] + const prevAssociations = AssociationsSchema.parse(prevTask.associations) || [] + const currentShared = this.task.isShared + const prevShared = prevTask.isShared + // handles the case to show activity log when a task is shared with association if ( - (!!currentViewers.length || !!prevViewers.length) && - (currentViewers[0]?.clientId !== prevViewers[0]?.clientId || - currentViewers[0]?.companyId !== prevViewers[0]?.companyId) + (!!currentAssociations.length || !!prevAssociations.length) && + (currentAssociations[0]?.clientId !== prevAssociations[0]?.clientId || + currentAssociations[0]?.companyId !== prevAssociations[0]?.companyId || + currentShared !== prevShared) && + (currentShared || prevShared) ) { - const currentViewerId = currentViewers[0]?.clientId || currentViewers[0]?.companyId || null - const previousViewerId = prevViewers[0]?.clientId || prevViewers[0]?.companyId || null - if (currentViewerId) { - if (previousViewerId) await this.logTaskViewerRemoved(previousViewerId) // if previous viewer exists, log removed event - await this.logTaskViewerUpdated(previousViewerId, currentViewerId) + const currentAssociationId = currentAssociations[0]?.clientId || currentAssociations[0]?.companyId || null + const prevAssociationId = prevAssociations[0]?.clientId || prevAssociations[0]?.companyId || null + + if (currentAssociationId) { + if (prevAssociationId && currentAssociationId !== prevAssociationId && prevShared) + await this.logTaskViewerRemoved(prevAssociationId) // if previous viewer exists, log removed event + if (currentShared) { + await this.logTaskViewerUpdated(prevAssociationId, currentAssociationId) + } else { + await this.logTaskViewerRemoved(currentAssociationId) + } setUpdate() - } else { - await this.logTaskViewerRemoved(previousViewerId) + } else if (prevAssociationId && prevShared) { + await this.logTaskViewerRemoved(prevAssociationId) setUpdate() } } diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index af8b9a405..a3e456137 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.validateAssociations(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,16 +347,13 @@ export class TasksService extends TasksSharedService { companyId: validatedIds?.companyId ?? null, }) - let viewers: Viewers = ViewersSchema.parse(prevTask.viewers) - 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) - } - } + const associations = await this.resolveAssociations({ + prevTask, + data, + shouldUpdateUserIds, + clientId, + companyId, + }) const userAssignmentFields = shouldUpdateUserIds ? { @@ -401,7 +401,8 @@ export class TasksService extends TasksSharedService { archivedBy, completedBy, completedByUserType, - viewers, + associations, + isShared: this.validateTaskShare(prevTask, data), ...userAssignmentFields, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, @@ -518,7 +519,7 @@ export class TasksService extends TasksSharedService { { assigneeId, assigneeType: AssigneeType.company }, { companyId: assigneeId, clientId: null }, { - viewers: { + associations: { hasSome: [{ clientId: null, companyId: assigneeId }], }, }, @@ -554,12 +555,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 +571,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 +586,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 +655,8 @@ export class TasksService extends TasksSharedService { clientId: true, companyId: true, internalUserId: true, - viewers: true, + associations: true, + isShared: true, }, }), ) as Promise[], diff --git a/src/app/api/tasks/tasksShared.service.ts b/src/app/api/tasks/tasksShared.service.ts index fb96d6dcb..105173ffc 100644 --- a/src/app/api/tasks/tasksShared.service.ts +++ b/src/app/api/tasks/tasksShared.service.ts @@ -1,8 +1,14 @@ import { maxSubTaskDepth } from '@/constants/tasks' import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' -import { InternalUsers, Uuid } from '@/types/common' +import { InternalUsers, TempClientFilter, Uuid } from '@/types/common' import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' -import { CreateTaskRequest, CreateTaskRequestSchema, Viewers } from '@/types/dto/tasks.dto' +import { + CreateTaskRequest, + CreateTaskRequestSchema, + Associations, + UpdateTaskRequest, + AssociationsSchema, +} from '@/types/dto/tasks.dto' import { getFileNameFromPath } from '@/utils/attachmentUtils' import { buildLtree, buildLtreeNodeString } from '@/utils/ltree' import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' @@ -29,7 +35,7 @@ export abstract class TasksSharedService extends BaseService { * If user is a client, return filter for just the tasks assigned to this clientId. * If user is a client and has a companyId, return filter for just the tasks assigned to this clientId `OR` to this companyId */ - protected buildTaskPermissions(id?: string, includeViewer: boolean = true) { + protected buildTaskPermissions(id?: string, includeAssociatedTask: boolean = true) { const user = this.user // Default filters @@ -39,15 +45,16 @@ export abstract class TasksSharedService extends BaseService { } if (user.clientId || user.companyId) { - filters = { ...filters, ...this.getClientOrCompanyAssigneeFilter(includeViewer) } + filters = { ...filters, ...this.getClientOrCompanyAssigneeFilter(includeAssociatedTask) } } return filters } - protected getClientOrCompanyAssigneeFilter(includeViewer: boolean = true): Prisma.TaskWhereInput { + protected getClientOrCompanyAssigneeFilter(includeAssociatedTask: boolean = true): Prisma.TaskWhereInput { const clientId = z.string().uuid().safeParse(this.user.clientId).data const companyId = z.string().uuid().parse(this.user.companyId) + const isCuPortal = !this.user.internalUserId && (clientId || companyId) const filters = [] @@ -58,29 +65,32 @@ export abstract class TasksSharedService extends BaseService { // Get company tasks for the client's companyId { companyId, clientId: null }, ) - if (includeViewer) - filters.push( - // Get tasks that includes the client as a viewer - { - viewers: { - hasSome: [{ clientId, companyId }, { companyId }], - }, + + // Get tasks that includes the client as a association + if (includeAssociatedTask) { + const tempClientFilter: TempClientFilter = { + associations: { + hasSome: [{ clientId, companyId }, { companyId }], }, - ) + } + if (isCuPortal) tempClientFilter.isShared = true + filters.push(tempClientFilter) + } } else if (companyId) { filters.push( // Get only company tasks for the client's companyId { clientId: null, companyId }, ) - if (includeViewer) - filters.push( - // Get tasks that includes the company as a viewer - { - viewers: { - hasSome: [{ companyId }], - }, + if (includeAssociatedTask) { + const tempCompanyFilter: TempClientFilter = { + associations: { + hasSome: [{ companyId }], }, - ) + } + if (isCuPortal) tempCompanyFilter.isShared = true + // Get tasks that includes the company as a viewer + filters.push(tempCompanyFilter) + } } return filters.length > 0 ? { OR: filters } : {} } @@ -156,7 +166,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 +367,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 validateAssociations(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 +505,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 +519,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. }) @@ -532,4 +542,69 @@ export abstract class TasksSharedService extends BaseService { ) } } + + protected validateTaskShare(prevTask: Task, data: UpdateTaskRequest): boolean | undefined { + const finalIsShared = data.isShared !== undefined ? data.isShared : prevTask.isShared + + const finalInternalUser = data.internalUserId !== undefined ? data.internalUserId : prevTask.internalUserId + + const finalAssociations = data.associations !== undefined ? data.associations : prevTask.associations + + if (!finalIsShared) return false + + const hasInternalUser = !!finalInternalUser + const hasAssociations = !!finalAssociations?.length + + if (!hasInternalUser || !hasAssociations) { + throw new APIError( + httpStatus.BAD_REQUEST, + 'Cannot share task. A task must have an internal user and at least one association to be shared.', + ) + } + + return true + } + + protected async resolveAssociations(params: { + prevTask: Task + data: UpdateTaskRequest + shouldUpdateUserIds: boolean + clientId?: string | null + companyId?: string | null + }): Promise { + const { prevTask, data, shouldUpdateUserIds, clientId, companyId } = params + if (!data.associations) { + return AssociationsSchema.parse(prevTask.associations) + } + + const shouldReset = this.shouldResetAssociations({ + shouldUpdateUserIds, + prevTask, + clientId, + companyId, + }) + + if (shouldReset) return [] + + const parsed = AssociationsSchema.parse(data.associations) + + if (!parsed?.length) return [] + + return this.validateAssociations(parsed) + } + + private shouldResetAssociations(params: { + shouldUpdateUserIds: boolean + prevTask: Task + clientId?: string | null + companyId?: string | null + }): boolean { + const { shouldUpdateUserIds, prevTask, clientId, companyId } = params + + if (shouldUpdateUserIds) { + return !!clientId || !!companyId + } + + return !!prevTask.clientId || !!prevTask.companyId + } } diff --git a/src/app/api/view-settings/viewSettings.service.ts b/src/app/api/view-settings/viewSettings.service.ts index 0dfa3e641..c5eb899de 100644 --- a/src/app/api/view-settings/viewSettings.service.ts +++ b/src/app/api/view-settings/viewSettings.service.ts @@ -36,8 +36,11 @@ export class ViewSettingsService extends BaseService { if (filterOptions && !filterOptions.creator) { viewSettings.filterOptions = { ...filterOptions, [FilterOptions.CREATOR]: emptyAssignee } } - if (filterOptions && !filterOptions.visibility) { - viewSettings.filterOptions = { ...filterOptions, [FilterOptions.VISIBILITY]: emptyAssignee } + if (filterOptions && !filterOptions.association) { + viewSettings.filterOptions = { ...filterOptions, [FilterOptions.ASSOCIATION]: emptyAssignee } + } + if (filterOptions && !filterOptions.isShared) { + viewSettings.filterOptions = { ...filterOptions, [FilterOptions.IS_SHARED]: emptyAssignee } } return viewSettings @@ -79,21 +82,10 @@ export class ViewSettingsService extends BaseService { workspaceId: this.user.workspaceId, viewMode: this.DEFAULT_VIEW_MODE, filterOptions: { - [FilterOptions.ASSIGNEE]: { - internalUserId: null, - clientId: null, - companyId: null, - }, - [FilterOptions.VISIBILITY]: { - internalUserId: null, - clientId: null, - companyId: null, - }, - [FilterOptions.CREATOR]: { - internalUserId: null, - clientId: null, - companyId: null, - }, + [FilterOptions.ASSIGNEE]: emptyAssignee, + [FilterOptions.ASSOCIATION]: emptyAssignee, + [FilterOptions.IS_SHARED]: emptyAssignee, + [FilterOptions.CREATOR]: emptyAssignee, [FilterOptions.KEYWORD]: '', [FilterOptions.TYPE]: '', }, 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..f18e2daaf 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,8 @@ export const updateAssignee = async ( internalUserId: string | null, clientId: string | null, companyId: string | null, - viewers?: Viewers, + associations?: Associations, + isShared?: boolean, ) => { await fetch(`${apiUrl}/api/tasks/${task_id}?token=${token}`, { method: 'PATCH', @@ -56,7 +57,8 @@ 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: clientId || companyId ? [] : 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. + isShared: isShared ?? undefined, }), }) } diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index aff41864b..b281d7684 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -37,7 +37,7 @@ import { RealTimeTemplates } from '@/hoc/RealtimeTemplates' import { WorkspaceResponse } from '@/types/common' import { AncestorTaskResponse, SubTaskStatusResponse, TaskResponse } from '@/types/dto/tasks.dto' import { UserType } from '@/types/interfaces' -import { getAssigneeCacheLookupKey, UserIdsWithViewersType } from '@/utils/assignee' +import { getAssigneeCacheLookupKey, UserIdsWithAssociationSharedType } from '@/utils/assignee' import { CopilotAPI } from '@/utils/CopilotAPI' import EscapeHandler from '@/utils/escapeHandler' import { getPreviewMode } from '@/utils/previewMode' @@ -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 ( { + updateAssignee={async ({ + internalUserId, + clientId, + companyId, + associations, + isShared, + }: UserIdsWithAssociationSharedType) => { 'use server' - await updateAssignee(token, task_id, internalUserId, clientId, companyId, viewers) + await updateAssignee(token, task_id, internalUserId, clientId, companyId, associations, isShared) }} updateTask={async (payload) => { 'use server' diff --git a/src/app/detail/ui/ActivityLog.tsx b/src/app/detail/ui/ActivityLog.tsx index 1f78efcf1..3f1a00cb0 100644 --- a/src/app/detail/ui/ActivityLog.tsx +++ b/src/app/detail/ui/ActivityLog.tsx @@ -146,17 +146,15 @@ export const ActivityLog = ({ log }: Prop) => { [ActivityType.COMMENT_ADDED]: () => null, [ActivityType.VIEWER_ADDED]: (_from: string, to: string) => ( <> - added + shared the task with {to} - as a viewer ), [ActivityType.VIEWER_REMOVED]: (from: string) => ( <> - removed + stopped sharing the task with {from} - as a viewer ), diff --git a/src/app/detail/ui/NewTaskCard.tsx b/src/app/detail/ui/NewTaskCard.tsx index c5105b79e..0473cde81 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 { CopilotToggle } from '@/components/inputs/CopilotToggle' 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,17 @@ export const NewTaskCard = ({ /> )} + {showShareToggle && ( + { + setIsShared(!isShared) + handleFieldChange('isShared', !isShared) + }} + checked={isShared} + /> + )} void - updateAssignee: (userIds: UserIdsWithViewersType) => void + updateAssignee: (userIds: UserIdsWithAssociationSharedType) => void updateTask: (payload: UpdateTaskRequest) => void disabled: boolean workflowDisabled?: boolean @@ -96,12 +98,20 @@ export const Sidebar = ({ (activeTask?.assigneeId === tokenPayload?.clientId || activeTask?.assigneeId === tokenPayload?.companyId) const [dueDate, setDueDate] = useState() - const [showConfirmViewershipModal, setShowConfirmViewershipModal] = useState(false) //this is used only in sidebar. + const [showAssociationConfirmationModal, setAssociationConfirmationModal] = useState(false) //this is used only in sidebar. + const [selectorFieldType, setSelectorFieldType] = useState(null) const [assigneeValue, setAssigneeValue] = useState() const [selectedAssignee, setSelectedAssignee] = useState(undefined) - const [taskViewerValue, setTaskViewerValue] = useState(null) + const [taskAssociationValue, setTaskAssociationValue] = useState(null) + const [selectedAssociationUser, setSelectedAssociationUser] = useState() + + const [isTaskShared, setIsTaskShared] = useState(false) + + const baseAssociationCondition = assigneeValue && assigneeValue.type === FilterByOptions.IUS + const showShareToggle = baseAssociationCondition && taskAssociationValue + const showAssociation = !assigneeValue || baseAssociationCondition const { renderingItem: _statusValue, updateRenderingItem: updateStatusValue } = useHandleSelectorComponent({ // item: selectedWorkflowState, @@ -129,26 +139,54 @@ export const Sidebar = ({ if (activeTask && assignee.length > 0) { const currentAssignee = getSelectorAssigneeFromTask(assignee, activeTask) setAssigneeValue(currentAssignee) - setTaskViewerValue(getSelectorViewerFromTask(assignee, activeTask) || null) + const currentAssociations = getSelectorAssociationFromTask(assignee, activeTask) || null + setTaskAssociationValue(currentAssociations) + setIsTaskShared(!!activeTask.isShared) } }, [assignee, activeTask]) const windowWidth = useWindowWidth() const isMobile = windowWidth < 800 && windowWidth !== 0 - const checkViewersCompatibility = (userIds: UserIdsType): UserIdsWithViewersType => { - // remove task viewers if assignee is cleared or changed to client or company - if (!userIds.internalUserId) { - setTaskViewerValue(null) - return { ...userIds, viewers: [] } // remove viewers if assignee is cleared or changed to client or company + const checkForAssociationAndShared = (userIds: UserIdsType): UserIdsWithAssociationSharedType => { + const { internalUserId, clientId, companyId } = userIds + + if (internalUserId) return userIds + + const noAssignee = !internalUserId && !clientId && !companyId + const temp: Partial = {} + + if (isTaskShared) { + temp.isShared = false + setIsTaskShared(false) } - return userIds // no viewers change. keep viewers as is. + + if (!noAssignee) { + temp.associations = [] // remove association only if assignee is non empty and not IU + setTaskAssociationValue(null) + } + return { ...userIds, ...temp } // remove task shared if assignee is cleared or changed to client or company } const handleConfirmAssigneeChange = (userIds: UserIdsType) => { - updateAssignee(checkViewersCompatibility(userIds)) + updateAssignee(checkForAssociationAndShared(userIds)) setAssigneeValue(getAssigneeValue(userIds) as IAssigneeCombined) - showConfirmAssignModal ? store.dispatch(toggleShowConfirmAssignModal()) : setShowConfirmViewershipModal(false) + showConfirmAssignModal ? store.dispatch(toggleShowConfirmAssignModal()) : setAssociationConfirmationModal(false) + setSelectorFieldType(null) + } + + const handleConfirmAssociationChange = (userIds: UserIdsType) => { + updateAssignee({ + internalUserId: assigneeValue?.id || null, + clientId: null, + companyId: null, + isShared: false, + associations: selectedAssociationUser, + }) + setTaskAssociationValue(getAssigneeValue(userIds) as IAssigneeCombined) + setIsTaskShared(false) + setSelectorFieldType(null) + setAssociationConfirmationModal(false) } useEffect(() => { @@ -171,39 +209,65 @@ export const Sidebar = ({ } if (!activeTask || !isHydrated) return + const handleAssigneeChange = (inputValue: InputValue[]) => { + setSelectorFieldType(SelectorFieldType.ASSIGNEE) const newUserIds = getSelectedUserIds(inputValue) const previousAssignee = assignee.find((assignee) => assignee.id == getAssigneeId(getUserIds(activeTask))) const nextAssignee = getSelectorAssignee(assignee, inputValue) const shouldShowConfirmModal = shouldConfirmBeforeReassignment(previousAssignee, nextAssignee) - const shouldShowConfirmViewershipModal = shouldConfirmViewershipBeforeReassignment(taskViewerValue, nextAssignee) + const showAssociationConfirmModal = shouldConfirmTaskSharedBeforeReassignment( + taskAssociationValue, + isTaskShared, + nextAssignee, + ) if (shouldShowConfirmModal) { setSelectedAssignee(newUserIds) store.dispatch(toggleShowConfirmAssignModal()) - } else if (shouldShowConfirmViewershipModal) { + } else if (showAssociationConfirmModal) { setSelectedAssignee(newUserIds) - setShowConfirmViewershipModal(true) + setAssociationConfirmationModal(true) } else { setAssigneeValue(getAssigneeValue(newUserIds) as IAssigneeCombined) - updateAssignee(checkViewersCompatibility(newUserIds)) + updateAssignee(checkForAssociationAndShared(newUserIds)) + if (newUserIds.clientId || newUserIds.companyId) { + setTaskAssociationValue(null) + } + setSelectorFieldType(null) } } - const handleTaskViewerChange = (inputValue: InputValue[]) => { - if (assigneeValue && assigneeValue.type === FilterByOptions.IUS) { - const newTaskViewerIds = getSelectedViewerIds(inputValue) - setTaskViewerValue(getSelectorAssignee(assignee, inputValue) || null) - - newTaskViewerIds && - updateAssignee({ - internalUserId: assigneeValue.id, - clientId: null, - companyId: null, - viewers: newTaskViewerIds, - }) + const handleTaskAssociationChange = (inputValue: InputValue[]) => { + setSelectorFieldType(SelectorFieldType.ASSOCIATION) + const newTaskAssociationIds = getSelectedViewerIds(inputValue) + const showModal = shouldConfirmTaskSharedBeforeReassignment(taskAssociationValue, isTaskShared) + if (showModal) { + setSelectedAssociationUser(newTaskAssociationIds) + setAssociationConfirmationModal(true) + } else if (newTaskAssociationIds) { + setTaskAssociationValue(getSelectorAssignee(assignee, inputValue) || null) + updateAssignee({ + internalUserId: assigneeValue ? assigneeValue.id : null, + clientId: null, + companyId: null, + associations: newTaskAssociationIds, + isShared: newTaskAssociationIds.length ? isTaskShared : false, + }) + setSelectorFieldType(null) } } + const handleTaskShared = () => { + setIsTaskShared((prev) => !prev) + + updateAssignee({ + internalUserId: assigneeValue?.id || null, + clientId: null, + companyId: null, + isShared: !isTaskShared, + }) + } + if (!showSidebar || fromNotificationCenter) { return ( } + startIcon={} buttonContent={ (taskViewerValue ? theme.color.gray[600] : theme.color.gray[400]), + color: (theme) => (taskAssociationValue ? theme.color.gray[600] : theme.color.gray[400]), textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', maxWidth: '135px', }} > - {getAssigneeName(taskViewerValue || undefined, 'Set client visibility')} + {getAssigneeName(taskAssociationValue || undefined, 'Set related to')} } /> @@ -337,9 +401,9 @@ export const Sidebar = ({ )} - showConfirmAssignModal ? store.dispatch(toggleShowConfirmAssignModal()) : setShowConfirmViewershipModal(false) + showConfirmAssignModal ? store.dispatch(toggleShowConfirmAssignModal()) : setAssociationConfirmationModal(false) } aria-labelledby="confirm-reassignment-modal" aria-describedby="confirm-reassignment" @@ -347,14 +411,25 @@ export const Sidebar = ({ { setSelectedAssignee(undefined) - showConfirmAssignModal ? store.dispatch(toggleShowConfirmAssignModal()) : setShowConfirmViewershipModal(false) + setSelectedAssociationUser(undefined) + setSelectorFieldType(null) + showConfirmAssignModal + ? store.dispatch(toggleShowConfirmAssignModal()) + : setAssociationConfirmationModal(false) }} handleConfirm={() => { - if (selectedAssignee) { + if (selectorFieldType === SelectorFieldType.ASSIGNEE && selectedAssignee) { handleConfirmAssigneeChange(selectedAssignee) + } else if (selectorFieldType === SelectorFieldType.ASSOCIATION) { + selectedAssociationUser && + handleConfirmAssociationChange({ + internalUserId: null, + clientId: selectedAssociationUser[0]?.clientId ?? null, + companyId: selectedAssociationUser[0]?.companyId ?? null, + }) } }} - buttonText={showConfirmViewershipModal ? 'Remove' : 'Reassign'} + buttonText={showAssociationConfirmationModal ? 'Remove' : 'Reassign'} description={ showConfirmAssignModal ? ( <> @@ -366,13 +441,17 @@ export const Sidebar = ({ ) : ( <> - {getAssigneeName(getAssigneeValueFromViewers(taskViewerValue, assignee))} will also lose - visibility to the task. + The task will be stopped sharing with{' '} + {getAssigneeName(getAssigneeValueFromAssociations(taskAssociationValue, assignee))} ) } - title={showConfirmViewershipModal && isEmptyAssignee(selectedAssignee) ? 'Remove assignee?' : 'Reassign task?'} - variant={showConfirmViewershipModal ? 'danger' : 'default'} + title={ + showAssociationConfirmationModal && isEmptyAssignee(selectedAssignee) && selectorFieldType + ? `Remove ${selectorFieldType}?` + : 'Reassign task?' + } + variant={showAssociationConfirmationModal ? 'danger' : 'default'} /> @@ -517,10 +596,10 @@ export const Sidebar = ({ )} - {assigneeValue && assigneeValue.type === FilterByOptions.IUS && ( - + {showAssociation && ( + - Client visibility + Related to {assignee.length > 0 ? ( // show skeleton if assignee list is empty } + startIcon={} outlined={true} buttonContent={ (taskViewerValue ? theme.color.gray[600] : theme.color.gray[400]), + color: (theme) => (taskAssociationValue ? theme.color.gray[600] : theme.color.gray[400]), textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', @@ -558,7 +637,7 @@ export const Sidebar = ({ fontWeight: 400, }} > - {getAssigneeName(taskViewerValue || undefined, 'Set client visibility')} + {getAssigneeName(taskAssociationValue || undefined, 'Set related to')} } /> @@ -570,11 +649,23 @@ export const Sidebar = ({ )} )} + {showShareToggle && ( + <> + theme.color.borders.border, height: '1px' }} /> + + + )} - showConfirmAssignModal ? store.dispatch(toggleShowConfirmAssignModal()) : setShowConfirmViewershipModal(false) + showConfirmAssignModal ? store.dispatch(toggleShowConfirmAssignModal()) : setAssociationConfirmationModal(false) } aria-labelledby="confirm-reassignment-modal" aria-describedby="confirm-reassignment" @@ -582,14 +673,23 @@ export const Sidebar = ({ { setSelectedAssignee(undefined) - showConfirmAssignModal ? store.dispatch(toggleShowConfirmAssignModal()) : setShowConfirmViewershipModal(false) + setSelectedAssociationUser(undefined) + setSelectorFieldType(null) + showConfirmAssignModal ? store.dispatch(toggleShowConfirmAssignModal()) : setAssociationConfirmationModal(false) }} handleConfirm={() => { - if (selectedAssignee) { + if (selectorFieldType === SelectorFieldType.ASSOCIATION) { + selectedAssociationUser && + handleConfirmAssociationChange({ + internalUserId: null, + clientId: selectedAssociationUser[0]?.clientId ?? null, + companyId: selectedAssociationUser[0]?.companyId ?? null, + }) + } else if (selectorFieldType === SelectorFieldType.ASSIGNEE && selectedAssignee) { handleConfirmAssigneeChange(selectedAssignee) } }} - buttonText={showConfirmViewershipModal ? 'Remove' : 'Reassign'} + buttonText={showAssociationConfirmationModal ? 'Remove' : 'Reassign'} description={ showConfirmAssignModal ? ( <> @@ -601,13 +701,17 @@ export const Sidebar = ({ ) : ( <> - {getAssigneeName(getAssigneeValueFromViewers(taskViewerValue, assignee))} will also lose - visibility to the task. + The task will be stopped sharing with{' '} + {getAssigneeName(getAssigneeValueFromAssociations(taskAssociationValue, assignee))}. ) } - title={showConfirmViewershipModal && isEmptyAssignee(selectedAssignee) ? 'Remove assignee?' : 'Reassign task?'} - variant={showConfirmViewershipModal ? 'danger' : 'default'} + title={ + showAssociationConfirmationModal && selectorFieldType && isEmptyAssignee(selectedAssignee) + ? `Remove ${selectorFieldType}?` + : 'Reassign task?' + } + variant={showAssociationConfirmationModal ? 'danger' : 'default'} /> {isAssignedToCU && userType == UserType.CLIENT_USER && !previewMode && ( diff --git a/src/app/detail/ui/TaskCardList.tsx b/src/app/detail/ui/TaskCardList.tsx index 93b70927e..2b392074c 100644 --- a/src/app/detail/ui/TaskCardList.tsx +++ b/src/app/detail/ui/TaskCardList.tsx @@ -20,7 +20,7 @@ import { selectTaskBoard, setAssigneeCache, setConfirmAssigneeModalId, - setConfirmViewershipModalId, + setConfirmAssociationModalId, updateWorkflowStateIdByTaskId, } from '@/redux/features/taskBoardSlice' import store from '@/redux/store' @@ -31,7 +31,7 @@ import { IAssigneeCombined, InputValue, Sizes } from '@/types/interfaces' import { getAssigneeId, getAssigneeName, - getAssigneeValueFromViewers, + getAssigneeValueFromAssociations, getUserIds, isEmptyAssignee, UserIdsType, @@ -44,11 +44,11 @@ import { getSelectedUserIds, getSelectorAssignee, getSelectorAssigneeFromTask, - getSelectorViewerFromTask, + getSelectorAssociationFromTask, } from '@/utils/selector' import { shouldConfirmBeforeReassignment, - shouldConfirmViewershipBeforeReassignment, + shouldConfirmTaskSharedBeforeReassignment, } from '@/utils/shouldConfirmBeforeReassign' import { checkIfTaskViewer } from '@/utils/taskViewer' @@ -79,7 +79,7 @@ export const TaskCardList = ({ sx, disableNavigation = false, }: TaskCardListProps) => { - const { assignee, workflowStates, previewMode, token, confirmAssignModalId, assigneeCache, confirmViewershipModalId } = + const { assignee, workflowStates, previewMode, token, confirmAssignModalId, assigneeCache, confirmAssociationModalId } = useSelector(selectTaskBoard) const { tokenPayload } = useSelector(selectAuthDetails) @@ -116,17 +116,20 @@ export const TaskCardList = ({ const statusValue = _statusValue as WorkflowStateResponse const handleConfirmAssigneeChange = (userIds: UserIdsType) => { - const viewers = !userIds.internalUserId ? [] : undefined const { internalUserId, clientId, companyId } = userIds + const isAssigneeClient = !!(clientId || companyId) + const hasNoAssignee = !internalUserId && !isAssigneeClient + const associations = isAssigneeClient ? [] : undefined + const isShared = hasNoAssignee || isAssigneeClient ? false : undefined store.dispatch(setConfirmAssigneeModalId(undefined)) - store.dispatch(setConfirmViewershipModalId(undefined)) + store.dispatch(setConfirmAssociationModalId(undefined)) if (handleUpdate) { token && handleUpdate(task.id, { internalUserId, clientId, companyId }, () => - updateAssignee(token, task.id, internalUserId, clientId, companyId, viewers), + updateAssignee(token, task.id, internalUserId, clientId, companyId, associations, isShared), ) } else { - token && updateAssignee(token, task.id, internalUserId, clientId, companyId, viewers) + token && updateAssignee(token, task.id, internalUserId, clientId, companyId, associations, isShared) } } @@ -135,26 +138,30 @@ export const TaskCardList = ({ const previousAssignee = assignee.find((assignee) => assignee.id == getAssigneeId(getUserIds(task))) const nextAssignee = getSelectorAssignee(assignee, inputValue) const shouldShowConfirmModal = shouldConfirmBeforeReassignment(previousAssignee, nextAssignee) - const shouldShowConfirmViewershipModal = shouldConfirmViewershipBeforeReassignment( - getSelectorViewerFromTask(assignee, task) ?? null, + const showAssociationConfirmModal = shouldConfirmTaskSharedBeforeReassignment( + getSelectorAssociationFromTask(assignee, task) ?? null, + !!task.isShared, nextAssignee, ) if (shouldShowConfirmModal) { setSelectedAssignee(newUserIds) store.dispatch(setConfirmAssigneeModalId(task.id)) - } else if (shouldShowConfirmViewershipModal) { + } else if (showAssociationConfirmModal) { setSelectedAssignee(newUserIds) - store.dispatch(setConfirmViewershipModalId(task.id)) + store.dispatch(setConfirmAssociationModalId(task.id)) } else { - const viewers = !newUserIds.internalUserId ? [] : undefined const { internalUserId, clientId, companyId } = newUserIds + const isAssigneeClient = !!(clientId || companyId) + const hasNoAssignee = !internalUserId && !isAssigneeClient + const associations = isAssigneeClient ? [] : undefined + const isShared = hasNoAssignee || isAssigneeClient ? false : undefined if (handleUpdate) { token && handleUpdate(task.id, { assigneeId: nextAssignee?.id }, () => - updateAssignee(token, task.id, internalUserId, clientId, companyId, viewers), + updateAssignee(token, task.id, internalUserId, clientId, companyId, associations, isShared), ) } else { - token && updateAssignee(token, task.id, internalUserId, clientId, companyId, viewers) + token && updateAssignee(token, task.id, internalUserId, clientId, companyId, associations, isShared) } setAssigneeValue(nextAssignee ?? NoAssignee) } @@ -232,7 +239,7 @@ export const TaskCardList = ({ size={Sizes.MEDIUM} padding={'4px'} hoverColor={200} - disabled={checkIfTaskViewer(task.viewers, tokenPayload)} + disabled={checkIfTaskViewer(task.associations, tokenPayload)} /> {isTemp || variant === 'subtask-board' || disableNavigation ? ( @@ -394,11 +401,11 @@ export const TaskCardList = ({ )} { e.stopPropagation() store.dispatch(setConfirmAssigneeModalId(undefined)) - store.dispatch(setConfirmViewershipModalId(undefined)) + store.dispatch(setConfirmAssociationModalId(undefined)) }} aria-labelledby="confirm-reassignment-modal" aria-describedby="confirm-reassignment" @@ -407,14 +414,14 @@ export const TaskCardList = ({ handleCancel={() => { setSelectedAssignee(undefined) store.dispatch(setConfirmAssigneeModalId(undefined)) - store.dispatch(setConfirmViewershipModalId(undefined)) + store.dispatch(setConfirmAssociationModalId(undefined)) }} handleConfirm={() => { if (selectedAssignee) { handleConfirmAssigneeChange(selectedAssignee) } }} - buttonText={confirmViewershipModalId === task.id ? 'Remove' : 'Reassign'} + buttonText={confirmAssociationModalId === task.id ? 'Remove' : 'Reassign'} description={ confirmAssignModalId === task.id ? ( <> @@ -426,17 +433,21 @@ export const TaskCardList = ({ ) : ( <> + The task will be stopped sharing with{' '} - {getAssigneeName(getAssigneeValueFromViewers(getSelectorViewerFromTask(assignee, task) ?? null, assignee))} - {' '} - will also lose visibility to the task. + {getAssigneeName( + getAssigneeValueFromAssociations(getSelectorAssociationFromTask(assignee, task) ?? null, assignee), + )} + ) } title={ - confirmViewershipModalId === task.id && isEmptyAssignee(selectedAssignee) ? 'Remove assignee?' : 'Reassign task?' + confirmAssociationModalId === task.id && isEmptyAssignee(selectedAssignee) + ? 'Remove assignee?' + : 'Reassign task?' } - variant={confirmViewershipModalId === task.id ? 'danger' : 'default'} + variant={confirmAssociationModalId === task.id ? 'danger' : 'default'} /> diff --git a/src/app/globals.css b/src/app/globals.css index 3456607aa..3a6ba3e57 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,3 +1,7 @@ +:root { + --text-secondary: #6b6f76; +} + * { padding: 0; margin: 0; @@ -92,6 +96,20 @@ a { animation: fadeHighlight 1s forwards; } +/* Custom toggle wrapper to override design system styles */ +.copilot-toggle-wrapper { + & .cop-font-medium { + font-weight: 400; + color: var(--text-secondary); + } + & .cop-leading-tight { + line-height: 22px; + } + & .cop-flex { + align-items: center; + } +} + .overlapping-avatar { > *:not(:last-child) { margin-right: -6px; diff --git a/src/app/ui/Modal_NewTaskForm.tsx b/src/app/ui/Modal_NewTaskForm.tsx index 89bfea93d..c1408a92f 100644 --- a/src/app/ui/Modal_NewTaskForm.tsx +++ b/src/app/ui/Modal_NewTaskForm.tsx @@ -35,7 +35,8 @@ export const ModalNewTaskForm = ({ showModal, templateId, parentId, - viewers, + associations, + isShared, disableSubtaskTemplates, } = useSelector(selectCreateTask) @@ -86,7 +87,8 @@ export const ModalNewTaskForm = ({ ...userIds, dueDate: formattedDueDate, templateId, - ...(viewers && viewers.length > 0 && { viewers }), + ...(associations && associations.length > 0 && { associations }), + isShared, parentId, } diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index 75ee76c9c..5ef4abd38 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 { CopilotToggle } from '@/components/inputs/CopilotToggle' 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' @@ -112,7 +114,7 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => } const [assigneeValue, setAssigneeValue] = useState(getDefaultAssigneeValue) - const [taskViewerValue, setTaskViewerValue] = useState( + const [taskAssociationsValue, setTaskAssociationsValue] = useState( !!previewMode ? (getSelectorAssigneeFromFilterOptions( assignee, @@ -192,24 +194,24 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => } else if (!checkEmptyAssignee(filterOptions[FilterOptions.ASSIGNEE])) { store.dispatch(setCreateTaskFields({ targetField: 'userIds', value: filterOptions[FilterOptions.ASSIGNEE] })) } else if (filterOptions[FilterOptions.TYPE]) { - if (!assigneeValue) return - const correctedObject = getAssigneeTypeCorrected(assigneeValue) - if (!correctedObject) return - const newUserIds = getSelectedUserIds([{ ...assigneeValue, object: correctedObject }]) - store.dispatch(setCreateTaskFields({ targetField: 'userIds', value: newUserIds })) - - // set default task viewers when filter by type "My tasks" is applied on preview mode - if (!!previewMode && taskViewerValue) { - const correctedViewerObject = getAssigneeTypeCorrected(taskViewerValue) + // set default task associations when filter by type is applied on preview mode + if (!!previewMode && taskAssociationsValue) { + const correctedViewerObject = getAssigneeTypeCorrected(taskAssociationsValue) if (!correctedViewerObject) return store.dispatch( setCreateTaskFields({ - targetField: 'viewers', - value: getSelectedViewerIds([{ ...taskViewerValue, object: correctedViewerObject }]), + targetField: 'associations', + value: getSelectedViewerIds([{ ...taskAssociationsValue, object: correctedViewerObject }]), }), ) } + + if (!assigneeValue) return + const correctedObject = getAssigneeTypeCorrected(assigneeValue) + if (!correctedObject) return + const newUserIds = getSelectedUserIds([{ ...assigneeValue, object: correctedObject }]) + store.dispatch(setCreateTaskFields({ targetField: 'userIds', value: newUserIds })) } else { store.dispatch(setCreateTaskFields({ targetField: 'userIds', value: emptyAssignee })) } @@ -239,15 +241,27 @@ 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) { - setTaskViewerValue(null) - store.dispatch(setCreateTaskFields({ targetField: 'viewers', value: [] })) + if (inputValue.length === 0) { + store.dispatch(setCreateTaskFields({ targetField: 'isShared', value: false })) + } + if (inputValue.length && inputValue[0].object !== UserRole.IU) { + setTaskAssociationsValue(null) + store.dispatch( + setMultipleCreateTaskFields([ + { targetField: 'associations', value: [] }, + { targetField: 'isShared', value: false }, + ]), + ) } // if preview mode, auto-select current CU as viewer - if (!!previewMode && inputValue.length && inputValue[0].object === UserRole.IU && previewClientCompany.companyId) { - if (!taskViewerValue) - setTaskViewerValue( + if ( + !!previewMode && + ((inputValue.length && inputValue[0].object === UserRole.IU) || !inputValue.length) && + previewClientCompany.companyId + ) { + if (!taskAssociationsValue) + setTaskAssociationsValue( getSelectorAssigneeFromFilterOptions( assignee, { internalUserId: null, ...previewClientCompany }, // if preview mode, default select the respective client/company as viewer @@ -255,7 +269,7 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => ) store.dispatch( setCreateTaskFields({ - targetField: 'viewers', + targetField: 'associations', value: [{ clientId: previewClientCompany.clientId || undefined, companyId: previewClientCompany.companyId }], }), ) @@ -267,6 +281,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 && taskAssociationsValue + 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 })) + setTaskAssociationsValue(selectedTaskViewers || null) + store.dispatch(setCreateTaskFields({ targetField: 'associations', value: newUserIds })) }} buttonContent={ : + taskAssociationsValue ? ( + + ) : ( + + ) } height="30px" padding="4px 8px" @@ -385,7 +408,8 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => (taskViewerValue ? theme.color.gray[600] : theme.color.text.textDisabled), + color: (theme) => + taskAssociationsValue ? theme.color.gray[600] : theme.color.text.textDisabled, textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', @@ -393,7 +417,7 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => maxWidth: { xs: '60px', sm: '100px' }, }} > - {getAssigneeName(taskViewerValue as IAssigneeCombined, 'Client visibility')} + {getAssigneeName(taskAssociationsValue as IAssigneeCombined, 'Related to')} } /> @@ -402,6 +426,24 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => )} + {showSharedToggle && ( + theme.color.background.bgCallout, + borderRadius: '4px', + }} + > + { + const localSharedState = store.getState().createTask.isShared + store.dispatch(setCreateTaskFields({ targetField: 'isShared', value: !localSharedState })) + }} + checked={store.getState().createTask.isShared} + className="p-1.5 py-2" // px-1.5 is not working + /> + + )} {errorMessage && ( {errorMessage} 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/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) diff --git a/src/components/buttons/FilterChip.tsx b/src/components/buttons/FilterChip.tsx index 8902d7b4f..160409b07 100644 --- a/src/components/buttons/FilterChip.tsx +++ b/src/components/buttons/FilterChip.tsx @@ -45,7 +45,8 @@ export const FilterChip = ({ type, assignee }: FilterChipProps) => { const hideClientsAndCompanies = type === FilterType.Creator || (filterOptions.type === FilterOptionsKeywords.TEAM && type === FilterType.Assignee) const hideIus = - type === FilterType.Visibility || (filterOptions.type === FilterOptionsKeywords.CLIENTS && type === FilterType.Assignee) + [FilterType.Association, FilterType.IsShared].includes(type) || + (filterOptions.type === FilterOptionsKeywords.CLIENTS && type === FilterType.Assignee) return ( <> diff --git a/src/components/cards/TaskCard.tsx b/src/components/cards/TaskCard.tsx index fe67b0b87..fe5aab619 100644 --- a/src/components/cards/TaskCard.tsx +++ b/src/components/cards/TaskCard.tsx @@ -10,7 +10,7 @@ import { selectTaskBoard, setAssigneeCache, setConfirmAssigneeModalId, - setConfirmViewershipModalId, + setConfirmAssociationModalId, updateWorkflowStateIdByTaskId, } from '@/redux/features/taskBoardSlice' import store from '@/redux/store' @@ -20,7 +20,7 @@ import { IAssigneeCombined, InputValue, Sizes } from '@/types/interfaces' import { getAssigneeId, getAssigneeName, - getAssigneeValueFromViewers, + getAssigneeValueFromAssociations, getUserIds, isEmptyAssignee, UserIdsType, @@ -47,11 +47,11 @@ import { getSelectedUserIds, getSelectorAssignee, getSelectorAssigneeFromTask, - getSelectorViewerFromTask, + getSelectorAssociationFromTask, } from '@/utils/selector' import { shouldConfirmBeforeReassignment, - shouldConfirmViewershipBeforeReassignment, + shouldConfirmTaskSharedBeforeReassignment, } from '@/utils/shouldConfirmBeforeReassign' import z from 'zod' import { StyledModal } from '@/app/detail/ui/styledComponent' @@ -96,7 +96,7 @@ export const TaskCard = ({ task, href, workflowState, mode, subtasks, workflowDi accessibleTasks, showSubtasks, confirmAssignModalId, - confirmViewershipModalId, + confirmAssociationModalId, } = useSelector(selectTaskBoard) const subtaskCount = useSubtaskCount(task.id) @@ -132,11 +132,14 @@ export const TaskCard = ({ task, href, workflowState, mode, subtasks, workflowDi }, [task.dueDate]) const handleConfirmAssigneeChange = (userIds: UserIdsType) => { + const isAssigneeClient = !!(userIds.clientId || userIds.companyId) + const hasNoAssignee = !userIds.internalUserId && !isAssigneeClient + const associations = isAssigneeClient ? [] : undefined + const isShared = hasNoAssignee || isAssigneeClient ? false : undefined const { internalUserId, clientId, companyId } = userIds - const viewers = !internalUserId ? [] : undefined store.dispatch(setConfirmAssigneeModalId(undefined)) - store.dispatch(setConfirmViewershipModalId(undefined)) - token && updateAssignee(token, task.id, internalUserId, clientId, companyId, viewers) + store.dispatch(setConfirmAssociationModalId(undefined)) + token && updateAssignee(token, task.id, internalUserId, clientId, companyId, associations, isShared) } const handleAssigneeChange = (inputValue: InputValue[]) => { @@ -144,23 +147,28 @@ export const TaskCard = ({ task, href, workflowState, mode, subtasks, workflowDi const previousAssignee = assignee.find((assignee) => assignee.id == getAssigneeId(getUserIds(task))) const nextAssignee = getSelectorAssignee(assignee, inputValue) const shouldShowConfirmModal = shouldConfirmBeforeReassignment(previousAssignee, nextAssignee) - const shouldShowConfirmViewershipModal = shouldConfirmViewershipBeforeReassignment( - getSelectorViewerFromTask(assignee, task) ?? null, + const showAssociationConfirmModal = shouldConfirmTaskSharedBeforeReassignment( + getSelectorAssociationFromTask(assignee, task) ?? null, + !!task.isShared, nextAssignee, ) if (shouldShowConfirmModal) { setSelectedAssignee(newUserIds) store.dispatch(setConfirmAssigneeModalId(task.id)) - } else if (shouldShowConfirmViewershipModal) { + } else if (showAssociationConfirmModal) { setSelectedAssignee(newUserIds) - store.dispatch(setConfirmViewershipModalId(task.id)) + store.dispatch(setConfirmAssociationModalId(task.id)) } else { const { internalUserId, clientId, companyId } = newUserIds - const viewers = !internalUserId ? [] : undefined - token && updateAssignee(token, task.id, internalUserId, clientId, companyId, viewers) + const isAssigneeClient = !!(clientId || companyId) + const hasNoAssignee = !internalUserId && !isAssigneeClient + const associations = isAssigneeClient ? [] : undefined + const isShared = hasNoAssignee || isAssigneeClient ? false : undefined + token && updateAssignee(token, task.id, internalUserId, clientId, companyId, associations, isShared) setAssigneeValue(nextAssignee ?? NoAssignee) } } + const getAssigneeValue = (userIds?: UserIdsType) => { if (!userIds) { return NoAssignee @@ -330,11 +338,11 @@ export const TaskCard = ({ task, href, workflowState, mode, subtasks, workflowDi )} { e.stopPropagation() store.dispatch(setConfirmAssigneeModalId(undefined)) - store.dispatch(setConfirmViewershipModalId(undefined)) + store.dispatch(setConfirmAssociationModalId(undefined)) }} aria-labelledby="confirm-reassignment-modal" aria-describedby="confirm-reassignment" @@ -343,14 +351,14 @@ export const TaskCard = ({ task, href, workflowState, mode, subtasks, workflowDi handleCancel={() => { setSelectedAssignee(undefined) store.dispatch(setConfirmAssigneeModalId(undefined)) - store.dispatch(setConfirmViewershipModalId(undefined)) + store.dispatch(setConfirmAssociationModalId(undefined)) }} handleConfirm={() => { if (selectedAssignee) { handleConfirmAssigneeChange(selectedAssignee) } }} - buttonText={confirmViewershipModalId === task.id ? 'Remove' : 'Reassign'} + buttonText={confirmAssociationModalId === task.id ? 'Remove' : 'Reassign'} description={ confirmAssignModalId === task.id ? ( <> @@ -362,17 +370,21 @@ export const TaskCard = ({ task, href, workflowState, mode, subtasks, workflowDi ) : ( <> + The task will be stopped sharing with{' '} - {getAssigneeName(getAssigneeValueFromViewers(getSelectorViewerFromTask(assignee, task) ?? null, assignee))} - {' '} - will also lose visibility to the task. + {getAssigneeName( + getAssigneeValueFromAssociations(getSelectorAssociationFromTask(assignee, task) ?? null, assignee), + )} + ) } title={ - confirmViewershipModalId === task.id && isEmptyAssignee(selectedAssignee) ? 'Remove assignee?' : 'Reassign task?' + confirmAssociationModalId === task.id && isEmptyAssignee(selectedAssignee) + ? 'Remove assignee?' + : 'Reassign task?' } - variant={confirmViewershipModalId === task.id ? 'danger' : 'default'} + variant={confirmAssociationModalId === task.id ? 'danger' : 'default'} /> diff --git a/src/components/inputs/CopilotToggle.tsx b/src/components/inputs/CopilotToggle.tsx new file mode 100644 index 000000000..aa4b3cc10 --- /dev/null +++ b/src/components/inputs/CopilotToggle.tsx @@ -0,0 +1,17 @@ +import { cn } from '@/utils/twMerge' +import { Toggle } from 'copilot-design-system' + +type CopilotToggleProps = { + label: string + onChange: () => void + checked: boolean + disabled?: boolean +} & React.HTMLAttributes + +export const CopilotToggle = ({ label, onChange, checked, className, disabled }: CopilotToggleProps) => { + return ( +
+ +
+ ) +} diff --git a/src/components/inputs/FilterSelector/FilterAssigneeSection.tsx b/src/components/inputs/FilterSelector/FilterAssigneeSection.tsx index cb6d28bb3..099e43c83 100644 --- a/src/components/inputs/FilterSelector/FilterAssigneeSection.tsx +++ b/src/components/inputs/FilterSelector/FilterAssigneeSection.tsx @@ -7,7 +7,6 @@ import { FilterOptions, FilterOptionsKeywords, InputValue } from '@/types/interf import { parseAssigneeToSelectorOption } from '@/utils/addTypeToAssignee' import { getWorkspaceLabels } from '@/utils/getWorkspaceLabels' import { getSelectedUserIds } from '@/utils/selector' -import { Visibility } from '@mui/icons-material' import { Box } from '@mui/material' import { Dispatch, SetStateAction } from 'react' import { useSelector } from 'react-redux' @@ -20,7 +19,8 @@ interface FilterAssigneeSectionProps { export const filterOptionsMap = { [FilterType.Assignee]: FilterOptions.ASSIGNEE, [FilterType.Creator]: FilterOptions.CREATOR, - [FilterType.Visibility]: FilterOptions.VISIBILITY, + [FilterType.Association]: FilterOptions.ASSOCIATION, + [FilterType.IsShared]: FilterOptions.IS_SHARED, } export const FilterAssigneeSection = ({ filterMode, setAnchorEl }: FilterAssigneeSectionProps) => { @@ -36,7 +36,8 @@ export const FilterAssigneeSection = ({ filterMode, setAnchorEl }: FilterAssigne const hideClientsAndCompanies = filterMode === FilterType.Creator || (type === FilterOptionsKeywords.TEAM && filterMode === FilterType.Assignee) const hideIus = - filterMode === FilterType.Visibility || (type === FilterOptionsKeywords.CLIENTS && filterMode === FilterType.Assignee) + [FilterType.Association, FilterType.IsShared].includes(filterMode) || + (type === FilterOptionsKeywords.CLIENTS && filterMode === FilterType.Assignee) const handleChange = (inputValue: InputValue[]) => { const newUserIds = getSelectedUserIds(inputValue) diff --git a/src/components/inputs/FilterSelector/FilterTypeSection.tsx b/src/components/inputs/FilterSelector/FilterTypeSection.tsx index 06fdfbcd5..5c8b09337 100644 --- a/src/components/inputs/FilterSelector/FilterTypeSection.tsx +++ b/src/components/inputs/FilterSelector/FilterTypeSection.tsx @@ -16,7 +16,7 @@ export const FilterTypeSection = ({ setFilterMode, filterModes }: FilterTypeSect filterOptions: { type }, } = useSelector(selectTaskBoard) - const disabled = type === FilterOptionsKeywords.CLIENTS ? [FilterType.Visibility] : [] + const disabled = type === FilterOptionsKeywords.CLIENTS || FilterOptionsKeywords.UNASSIGNED ? [FilterType.IsShared] : [] const removed = type.length > 20 ? [FilterType.Assignee] : [] return ( @@ -68,7 +68,9 @@ export const FilterTypeSection = ({ setFilterMode, filterModes }: FilterTypeSect -
Client visibility is only available
+
+ Shared with is only available +
for tasks assigned to internal users.
} diff --git a/src/components/inputs/FilterSelector/index.tsx b/src/components/inputs/FilterSelector/index.tsx index 4a987357a..0615ef2e4 100644 --- a/src/components/inputs/FilterSelector/index.tsx +++ b/src/components/inputs/FilterSelector/index.tsx @@ -13,7 +13,7 @@ type FilterSelectorProps = { disabled?: boolean } -const FILTER_MODES = [FilterType.Assignee, FilterType.Visibility, FilterType.Creator] +const FILTER_MODES = [FilterType.Assignee, FilterType.Association, FilterType.IsShared, FilterType.Creator] export const FilterSelector = ({ disabled }: FilterSelectorProps) => { const [filterMode, setFilterMode] = useState(null) @@ -22,13 +22,14 @@ export const FilterSelector = ({ disabled }: FilterSelectorProps) => { const id = open ? 'filter-selector-popper' : '' const { - filterOptions: { assignee, creator, visibility }, + filterOptions: { assignee, creator, association, isShared }, } = useSelector(selectTaskBoard) const filterModes = FILTER_MODES.filter((mode) => { if (mode === FilterType.Assignee && !isEmptyAssignee(assignee)) return false if (mode === FilterType.Creator && !isEmptyAssignee(creator)) return false - if (mode === FilterType.Visibility && !isEmptyAssignee(visibility)) return false + if (mode === FilterType.Association && !isEmptyAssignee(association)) return false + if (mode === FilterType.IsShared && !isEmptyAssignee(isShared)) return false return true }) diff --git a/src/components/layouts/SecondaryFilterBar.tsx b/src/components/layouts/SecondaryFilterBar.tsx index 7b7dc9d86..f86dec440 100644 --- a/src/components/layouts/SecondaryFilterBar.tsx +++ b/src/components/layouts/SecondaryFilterBar.tsx @@ -16,7 +16,8 @@ export const SecondaryFilterBar = ({ mode }: SecondaryFilterBarProps) => { return ( - + + diff --git a/src/hooks/useFilter.tsx b/src/hooks/useFilter.tsx index a1cf66531..dbbde26f0 100644 --- a/src/hooks/useFilter.tsx +++ b/src/hooks/useFilter.tsx @@ -19,7 +19,8 @@ interface KeywordMatchable { const FilterFunctions = { [FilterOptions.ASSIGNEE]: filterByAssignee, [FilterOptions.CREATOR]: filterByCreator, - [FilterOptions.VISIBILITY]: filterByClientVisibility, + [FilterOptions.ASSOCIATION]: filterByClientAssociation, + [FilterOptions.IS_SHARED]: filterByClientAssociation, [FilterOptions.KEYWORD]: filterByKeyword, [FilterOptions.TYPE]: filterByType, } @@ -50,7 +51,11 @@ function filterByAssignee(filteredTasks: TaskResponse[], filterValue: UserIdsTyp return filteredTasks } -function filterByClientVisibility(filteredTasks: TaskResponse[], filterValue: UserIdsType): TaskResponse[] { +function filterByClientAssociation( + filteredTasks: TaskResponse[], + filterValue: UserIdsType, + includeShared?: boolean, +): TaskResponse[] { const assigneeUserIds = filterValue if (checkEmptyAssignee(assigneeUserIds)) { @@ -59,11 +64,19 @@ function filterByClientVisibility(filteredTasks: TaskResponse[], filterValue: Us const { [UserIds.CLIENT_ID]: clientId, [UserIds.COMPANY_ID]: companyId } = assigneeUserIds if (clientId) { - filteredTasks = filteredTasks.filter( - (task) => task.viewers?.[0]?.clientId === clientId && task.viewers?.[0]?.companyId === companyId, - ) + filteredTasks = filteredTasks.filter((task) => { + const isAssociated = task.associations?.[0]?.clientId === clientId && task.associations?.[0]?.companyId === companyId + if (includeShared) return isAssociated && task.isShared + + return isAssociated + }) } else if (companyId && !clientId) { - filteredTasks = filteredTasks.filter((task) => task.viewers?.[0]?.companyId === companyId && !task.viewers?.[0].clientId) + filteredTasks = filteredTasks.filter((task) => { + const isAssociated = task.associations?.[0]?.companyId === companyId && !task.associations?.[0].clientId + if (includeShared) return isAssociated && task.isShared + + return isAssociated + }) } return filteredTasks @@ -129,12 +142,15 @@ 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: return filteredTasks.filter((task) => task?.assigneeType?.includes('internalUser')) + case FilterOptionsKeywords.UNASSIGNED: + return filteredTasks.filter((task) => !task.assigneeId) + default: return filteredTasks.filter((task) => task.assigneeId == assigneeType) } @@ -153,9 +169,17 @@ export const useFilter = (filterOptions: IFilterOptions, isPreviewMode: boolean) const assigneeFilterValue = UserIdsSchema.parse(filterValue) filteredTasks = FilterFunctions[FilterOptions.ASSIGNEE](filteredTasks, assigneeFilterValue) } - if (filterType === FilterOptions.CREATOR || filterType === FilterOptions.VISIBILITY) { + if ( + filterType === FilterOptions.CREATOR || + filterType === FilterOptions.ASSOCIATION || + filterType === FilterOptions.IS_SHARED + ) { + let includeShared = false + if (filterType === FilterOptions.IS_SHARED) { + includeShared = true + } const assigneeFilterValue = UserIdsSchema.parse(filterValue) - filteredTasks = FilterFunctions[filterType](filteredTasks, assigneeFilterValue) + filteredTasks = FilterFunctions[filterType](filteredTasks, assigneeFilterValue, includeShared) } if (filterType === FilterOptions.KEYWORD) { filteredTasks = FilterFunctions[FilterOptions.KEYWORD]( diff --git a/src/hooks/useFilterBar.tsx b/src/hooks/useFilterBar.tsx index cc0146438..bf137bf93 100644 --- a/src/hooks/useFilterBar.tsx +++ b/src/hooks/useFilterBar.tsx @@ -57,22 +57,27 @@ export const useFilterBar = () => { const iuFilterButtons = [ { - name: 'My tasks', + name: 'Me', onClick: () => handleFilterTypeClick({ filterTypeValue: IUTokenSchema.parse(tokenPayload).internalUserId }), id: 'MyTasks', }, { - name: 'Team tasks', + name: 'Team', onClick: () => handleFilterTypeClick({ filterTypeValue: FilterOptionsKeywords.TEAM }), id: 'TeamTasks', }, { - name: `${getWorkspaceLabels(workspace, true).individualTerm} tasks`, + name: `${getWorkspaceLabels(workspace, true).individualTerm}`, onClick: () => handleFilterTypeClick({ filterTypeValue: FilterOptionsKeywords.CLIENTS }), id: 'ClientTasks', }, { - name: 'All tasks', + name: 'Unassigned', + onClick: () => handleFilterTypeClick({ filterTypeValue: FilterOptionsKeywords.UNASSIGNED }), + id: 'UnassignedTasks', + }, + { + name: 'All', onClick: () => handleFilterTypeClick({ filterTypeValue: '' }), id: 'AllTasks', }, @@ -80,12 +85,12 @@ export const useFilterBar = () => { const clientFilterButtons = [ { - name: 'All tasks', + name: 'All', onClick: () => handleFilterTypeClick({ filterTypeValue: FilterOptionsKeywords.CLIENT_WITH_VIEWERS }), id: 'AllTasks', }, { - name: 'My tasks', + name: 'Me', onClick: () => handleFilterTypeClick({ filterTypeValue: FilterOptionsKeywords.CLIENTS }), id: 'MyTasks', }, @@ -93,19 +98,24 @@ export const useFilterBar = () => { const previewFilterButtons = [ { - name: 'My tasks', + name: 'Me', onClick: () => { handleFilterTypeClick({ filterTypeValue: IUTokenSchema.parse(tokenPayload).internalUserId }) }, id: 'MyTasks', }, { - name: 'Team tasks', + name: 'Team', onClick: () => handleFilterTypeClick({ filterTypeValue: FilterOptionsKeywords.TEAM }), id: 'TeamTasks', }, { - name: `${getWorkspaceLabels(workspace, true).individualTerm} tasks`, + name: 'Unassigned', + onClick: () => handleFilterTypeClick({ filterTypeValue: FilterOptionsKeywords.UNASSIGNED }), + id: 'UnassignedTasks', + }, + { + name: `${getWorkspaceLabels(workspace, true).individualTerm}`, onClick: () => handleFilterTypeClick({ filterTypeValue: FilterOptionsKeywords.CLIENTS }), id: 'ClientTasks', }, 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/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/redux/features/taskBoardSlice.tsx b/src/redux/features/taskBoardSlice.tsx index 28e288ead..e3509f114 100644 --- a/src/redux/features/taskBoardSlice.tsx +++ b/src/redux/features/taskBoardSlice.tsx @@ -27,7 +27,7 @@ interface IInitialState { accesibleTaskIds: string[] accessibleTasks: TaskResponse[] confirmAssignModalId: string | undefined - confirmViewershipModalId: string | undefined + confirmAssociationModalId: string | undefined assigneeCache: Record previewClientCompany: PreviewClientCompanyType urlActionParams: UrlActionParamsType @@ -42,7 +42,8 @@ const initialState: IInitialState = { filteredTasks: [], //contains tasks which are client-side filtered. is modified from the useFilter custom hook. filterOptions: { [FilterOptions.ASSIGNEE]: emptyAssignee, - [FilterOptions.VISIBILITY]: emptyAssignee, + [FilterOptions.ASSOCIATION]: emptyAssignee, + [FilterOptions.IS_SHARED]: emptyAssignee, [FilterOptions.CREATOR]: emptyAssignee, [FilterOptions.KEYWORD]: '', [FilterOptions.TYPE]: '', @@ -63,7 +64,7 @@ const initialState: IInitialState = { accesibleTaskIds: [], accessibleTasks: [], confirmAssignModalId: '', - confirmViewershipModalId: '', + confirmAssociationModalId: '', assigneeCache: {}, urlActionParams: { action: '', @@ -157,12 +158,12 @@ const taskBoardSlice = createSlice({ } } if ( - (filterOptions && filterOptions.visibility === undefined) || - (filterOptions?.visibility && !getAssignee(filterOptions.visibility)) + (filterOptions && filterOptions.association === undefined) || + (filterOptions?.association && !getAssignee(filterOptions.association)) ) { updatedFilterOptions = { ...updatedFilterOptions, - visibility: emptyAssignee, + association: emptyAssignee, } } if ( @@ -221,8 +222,8 @@ const taskBoardSlice = createSlice({ state.confirmAssignModalId = action.payload }, - setConfirmViewershipModalId: (state, action: { payload: string | undefined }) => { - state.confirmViewershipModalId = action.payload + setConfirmAssociationModalId: (state, action: { payload: string | undefined }) => { + state.confirmAssociationModalId = action.payload }, setAssigneeCache: (state, action: { payload: { key: string; value: IAssigneeCombined } }) => { @@ -255,7 +256,7 @@ export const { setAccesibleTaskIds, setAccessibleTasks, setConfirmAssigneeModalId, - setConfirmViewershipModalId, + setConfirmAssociationModalId, setAssigneeCache, setPreviewClientCompany, setUrlActionParams, 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/common.ts b/src/types/common.ts index eb18881fc..3d0b7526c 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -325,6 +325,20 @@ export type ViewSettingUserIdsType = z.infer */ export enum FilterType { Assignee = 'Assignee', - Visibility = 'Client Visibility', + Association = 'Related to', + IsShared = 'Shared with', Creator = 'Creator', } + +export enum SelectorFieldType { + ASSIGNEE = 'assignee', + ASSOCIATION = 'association', +} + +export const TempClientFilterSchema = z.object({ + associations: z.object({ + hasSome: z.array(z.object({ clientId: z.string().uuid().optional(), companyId: z.string().uuid() })), + }), + isShared: z.boolean().optional(), +}) +export type TempClientFilter = z.infer diff --git a/src/types/dto/tasks.dto.ts b/src/types/dto/tasks.dto.ts index 8c61f3c80..29613a85e 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,7 +97,8 @@ 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 + isShared: z.boolean().optional(), }) .superRefine(validateUserIds) @@ -97,7 +128,8 @@ export const TaskResponseSchema = z.object({ internalUserId: z.string().uuid().nullish(), clientId: z.string().uuid().nullish(), companyId: z.string().uuid().nullish(), - viewers: ViewersSchema, + associations: AssociationsSchema, + isShared: z.boolean().optional(), }) export type TaskResponse = z.infer @@ -109,7 +141,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/src/types/dto/viewSettings.dto.ts b/src/types/dto/viewSettings.dto.ts index 785932895..df5fd4c46 100644 --- a/src/types/dto/viewSettings.dto.ts +++ b/src/types/dto/viewSettings.dto.ts @@ -4,7 +4,8 @@ import { z } from 'zod' export const FilterOptionsSchema = z.object({ assignee: UserIdsSchema, - visibility: UserIdsSchema, + association: UserIdsSchema, + isShared: UserIdsSchema, creator: UserIdsSchema, keyword: z.string(), type: z.string(), diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts index 8135febe8..3b98e71f3 100644 --- a/src/types/interfaces.ts +++ b/src/types/interfaces.ts @@ -53,7 +53,8 @@ export enum FileTypes { export enum FilterOptions { ASSIGNEE = 'assignee', - VISIBILITY = 'visibility', + ASSOCIATION = 'association', + IS_SHARED = 'isShared', CREATOR = 'creator', KEYWORD = 'keyword', TYPE = 'type', @@ -69,6 +70,7 @@ export enum FilterByOptions { export enum FilterOptionsKeywords { CLIENTS = 'clients_companies', TEAM = 'ius', + UNASSIGNED = 'unassigned', CLIENT_WITH_VIEWERS = 'client_with_viewers', } @@ -92,11 +94,13 @@ export enum AttachmentTypes { export type IFilterOptions = { [key in FilterOptions]: key extends FilterOptions.ASSIGNEE ? UserIdsType - : key extends FilterOptions.VISIBILITY + : key extends FilterOptions.ASSOCIATION ? UserIdsType : key extends FilterOptions.CREATOR ? UserIdsType - : string + : key extends FilterOptions.IS_SHARED + ? UserIdsType + : string } export interface IAssignee { diff --git a/src/types/objectMaps.ts b/src/types/objectMaps.ts index fb55a4951..41e0c41af 100644 --- a/src/types/objectMaps.ts +++ b/src/types/objectMaps.ts @@ -5,7 +5,8 @@ import { StateType } from '@prisma/client' export const filterTypeToButtonIndexMap: Record = { [FilterOptionsKeywords.CLIENTS]: 2, [FilterOptionsKeywords.TEAM]: 1, - '': 3, + [FilterOptionsKeywords.UNASSIGNED]: 3, + '': 4, } export const clientFilterTypeToButtonIndexMap: Record = { @@ -14,8 +15,9 @@ export const clientFilterTypeToButtonIndexMap: Record = { } export const previewFilterTypeToButtonIndexMap: Record = { - [FilterOptionsKeywords.CLIENTS]: 2, [FilterOptionsKeywords.TEAM]: 1, + [FilterOptionsKeywords.UNASSIGNED]: 2, + [FilterOptionsKeywords.CLIENTS]: 3, } export const filterOptionsMap: Record = { diff --git a/src/utils/CopilotAPI.ts b/src/utils/CopilotAPI.ts index d734d02df..75e0f72d7 100644 --- a/src/utils/CopilotAPI.ts +++ b/src/utils/CopilotAPI.ts @@ -37,18 +37,18 @@ import { } from '@/types/common' import { DISPATCHABLE_EVENT } from '@/types/webhook' import Bottleneck from 'bottleneck' -import type { CopilotAPI as SDK } from 'copilot-node-sdk' -import { copilotApi } from 'copilot-node-sdk' +import type { AssemblyAPI as SDK } from '@assembly-js/node-sdk' +import { assemblyApi } from '@assembly-js/node-sdk' import { z } from 'zod' export class CopilotAPI { - copilot: SDK + private assemblyPromise: ReturnType constructor( private token: string, customApiKey?: string, ) { - this.copilot = copilotApi({ apiKey: customApiKey ?? apiKey, token }) + this.assemblyPromise = assemblyApi({ apiKey: customApiKey ?? apiKey, token }) } private async _manualFetch(route: string, query?: Record, workspaceId?: string) { @@ -76,9 +76,10 @@ export class CopilotAPI { // NOTE: Any method prefixed with _ is a API method that doesn't implement retry & delay // NOTE: Any normal API method name implements `withRetry` with default config - // Get Token Payload from copilot request token + // Get Token Payload from assembly request token async _getTokenPayload(): Promise { - const getTokenPayload = this.copilot.getTokenPayload + const assembly = await this.assemblyPromise + const getTokenPayload = assembly.getTokenPayload if (!getTokenPayload) { console.error(`CopilotAPI#getTokenPayload | Could not parse token payload for token ${this.token}`) return null @@ -89,13 +90,12 @@ export class CopilotAPI { async _me(): Promise { console.info('CopilotAPI#_me', this.token) + const assembly = await this.assemblyPromise const tokenPayload = await this.getTokenPayload() const id = tokenPayload?.internalUserId || tokenPayload?.clientId if (!tokenPayload || !id) return null - const retrieveCurrentUserInfo = tokenPayload.internalUserId - ? this.copilot.retrieveInternalUser - : this.copilot.retrieveClient + const retrieveCurrentUserInfo = tokenPayload.internalUserId ? assembly.retrieveInternalUser : assembly.retrieveClient const currentUserInfo = await retrieveCurrentUserInfo({ id }) return MeResponseSchema.parse(currentUserInfo) @@ -103,7 +103,8 @@ export class CopilotAPI { async _getWorkspace(): Promise { console.info('CopilotAPI#_getWorkspace', this.token) - return WorkspaceResponseSchema.parse(await this.copilot.retrieveWorkspace()) + const assembly = await this.assemblyPromise + return WorkspaceResponseSchema.parse(await assembly.retrieveWorkspace()) } async _getClientTokenPayload(): Promise { @@ -124,30 +125,33 @@ export class CopilotAPI { async _createClient(requestBody: ClientRequest, sendInvite: boolean = false): Promise { console.info('CopilotAPI#_createClient', this.token) - return ClientResponseSchema.parse(await this.copilot.createClient({ sendInvite, requestBody })) + const assembly = await this.assemblyPromise + return ClientResponseSchema.parse(await assembly.createClient({ sendInvite, requestBody })) } async _getClient(id: string): Promise { console.info('CopilotAPI#_getClient', this.token) - return ClientResponseSchema.parse(await this.copilot.retrieveClient({ id })) + const assembly = await this.assemblyPromise + return ClientResponseSchema.parse(await assembly.retrieveClient({ id })) } async _getClients(args: CopilotListArgs & { companyId?: string } = {}) { console.info('CopilotAPI#_getClients', this.token) + const assembly = await this.assemblyPromise const maxLimit = MAX_LIMIT_CLIENT_COUNT const requestedLimit = args.limit || maxLimit let clients: ClientResponse[] = [] let nextToken: string | undefined = undefined if (requestedLimit <= maxLimit) { - return ClientsResponseSchema.parse(await this.copilot.listClients(args)) + return ClientsResponseSchema.parse(await assembly.listClients(args)) } //fetching client data in batches of MAX_LIMIT_CLIENT_COUNT instead of fetching it as a whole. do { const remaining = requestedLimit - clients.length const fetchLimit = Math.min(maxLimit, remaining) - const response = await this.copilot.listClients({ + const response = await assembly.listClients({ ...args, limit: fetchLimit, nextToken, @@ -162,28 +166,33 @@ export class CopilotAPI { async _updateClient(id: string, requestBody: ClientRequest): Promise { console.info('CopilotAPI#_updateClient', this.token) + const assembly = await this.assemblyPromise // @ts-ignore - return ClientResponseSchema.parse(await this.copilot.updateClient({ id, requestBody })) + return ClientResponseSchema.parse(await assembly.updateClient({ id, requestBody })) } async _deleteClient(id: string) { console.info('CopilotAPI#_deleteClient', this.token) - return await this.copilot.deleteClient({ id }) + const assembly = await this.assemblyPromise + return await assembly.deleteClient({ id }) } async _createCompany(requestBody: CompanyCreateRequest) { console.info('CopilotAPI#_createCompany', this.token) - return CompanyResponseSchema.parse(await this.copilot.createCompany({ requestBody })) + const assembly = await this.assemblyPromise + return CompanyResponseSchema.parse(await assembly.createCompany({ requestBody })) } async _getCompany(id: string): Promise { console.info('CopilotAPI#_getCompany', this.token) - return CompanyResponseSchema.parse(await this.copilot.retrieveCompany({ id })) + const assembly = await this.assemblyPromise + return CompanyResponseSchema.parse(await assembly.retrieveCompany({ id })) } async _getCompanies(args: CopilotListArgs & { isPlaceholder?: boolean } = {}): Promise { console.info('CopilotAPI#_getCompanies', this.token) - return CompaniesResponseSchema.parse(await this.copilot.listCompanies(args)) + const assembly = await this.assemblyPromise + return CompaniesResponseSchema.parse(await assembly.listCompanies(args)) } async _getCompanyClients(companyId: string): Promise { @@ -193,28 +202,33 @@ export class CopilotAPI { async _getCustomFields(): Promise { console.info('CopilotAPI#_getCustomFields', this.token) - return CustomFieldResponseSchema.parse(await this.copilot.listCustomFields()) + const assembly = await this.assemblyPromise + return CustomFieldResponseSchema.parse(await assembly.listCustomFields({})) } async _getInternalUsers(args: CopilotListArgs = {}): Promise { console.info('CopilotAPI#_getInternalUsers', this.token) - return InternalUsersResponseSchema.parse(await this.copilot.listInternalUsers(args)) + const assembly = await this.assemblyPromise + return InternalUsersResponseSchema.parse(await assembly.listInternalUsers(args)) } async _getInternalUser(id: string): Promise { console.info('CopilotAPI#_getInternalUser', this.token) - return InternalUsersSchema.parse(await this.copilot.retrieveInternalUser({ id })) + const assembly = await this.assemblyPromise + return InternalUsersSchema.parse(await assembly.retrieveInternalUser({ id })) } async _createNotification(requestBody: NotificationRequestBody): Promise { console.info('CopilotAPI#_createNotification', this.token) - const notification = await this.copilot.createNotification({ requestBody }) + const assembly = await this.assemblyPromise + const notification = await assembly.createNotification({ requestBody }) return NotificationCreatedResponseSchema.parse(notification) } async _markNotificationAsRead(id: string): Promise { console.info('CopilotAPI#_markNotificationAsRead', this.token) - await this.copilot.markNotificationRead({ id }) + const assembly = await this.assemblyPromise + await assembly.markNotificationRead({ id }) } async _bulkMarkNotificationsAsRead(notificationIds: string[], shouldThrowError: boolean = true): Promise { @@ -247,7 +261,8 @@ export class CopilotAPI { async _deleteNotification(id: string): Promise { console.info('CopilotAPI#_deleteNotification', this.token) - await this.copilot.deleteNotification({ id }) + const assembly = await this.assemblyPromise + await assembly.deleteNotification({ id }) } async _bulkDeleteNotifications(notificationIds: string[]): Promise { diff --git a/src/utils/assignee.ts b/src/utils/assignee.ts index ed4f00587..2a465a48e 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, UserType } from '@/types/interfaces' import { getAssigneeTypeCorrected } from '@/utils/getAssigneeTypeCorrected' import { NoAssignee } from '@/utils/noAssignee' @@ -17,7 +17,7 @@ export const UserIdsSchema = z.object({ export type UserIdsType = z.infer -export type UserIdsWithViewersType = UserIdsType & { viewers?: Viewers } +export type UserIdsWithAssociationSharedType = UserIdsType & { associations?: Associations; isShared?: boolean } export const isAssigneeTextMatching = (newInputValue: string, assigneeValue: IAssigneeCombined): boolean => { const truncate = (newInputValue: string) => truncateText(newInputValue, TruncateMaxNumber.SELECTOR) @@ -106,7 +106,7 @@ export const isEmptyAssignee = (userIds?: UserIdsType) => { return Object.values(userIds).every((value) => value === null) } -export const getAssigneeValueFromViewers = (viewer: IAssigneeCombined | null, assignee: IAssigneeCombined[]) => { +export const getAssigneeValueFromAssociations = (viewer: IAssigneeCombined | null, assignee: IAssigneeCombined[]) => { if (!viewer) { return NoAssignee } @@ -119,8 +119,8 @@ export const getAssigneeValueFromViewers = (viewer: IAssigneeCombined | null, as return match ?? undefined } -export const getTaskViewers = (task: TaskResponse | Task | Pick) => { - const taskViewers = ViewersSchema.parse(task.viewers) - const viewer = !!taskViewers?.length ? taskViewers[0] : undefined - return viewer +export const getTaskAssociations = (task: TaskResponse | Task | Pick) => { + const taskAssociations = AssociationsSchema.parse(task.associations) + const association = !!taskAssociations?.length ? taskAssociations[0] : undefined + return association } diff --git a/src/utils/selector.ts b/src/utils/selector.ts index 82a1d5db2..16ad03602 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' @@ -58,12 +58,12 @@ export const getSelectorAssigneeFromTask = (assignee: IAssigneeCombined[], task: ) } //util to get initial assignee from task for selector. -export const getSelectorViewerFromTask = (assignee: IAssigneeCombined[], task: TaskResponse) => { +export const getSelectorAssociationFromTask = (assignee: IAssigneeCombined[], task: TaskResponse) => { 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/shouldConfirmBeforeReassign.ts b/src/utils/shouldConfirmBeforeReassign.ts index c3bf8e2c4..7d68cc1f6 100644 --- a/src/utils/shouldConfirmBeforeReassign.ts +++ b/src/utils/shouldConfirmBeforeReassign.ts @@ -34,13 +34,14 @@ export const shouldConfirmBeforeReassignment = ( } } -export const shouldConfirmViewershipBeforeReassignment = ( - viewer: IAssigneeCombined | null, +export const shouldConfirmTaskSharedBeforeReassignment = ( + association: IAssigneeCombined | null, + isTaskShared: boolean, currentAssignee?: IAssigneeCombined, ) => { - if (viewer) { + if (association && isTaskShared) { const assigneeType = currentAssignee && getAssigneeTypeCorrected(currentAssignee) - if (!assigneeType || (assigneeType !== AssigneeType.internalUser && currentAssignee.id !== viewer.id)) { + if (!assigneeType || (assigneeType !== AssigneeType.internalUser && currentAssignee.id !== association.id)) { //no assignee or assignee is a non-IU return true } 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) ) } diff --git a/src/utils/twMerge.ts b/src/utils/twMerge.ts new file mode 100644 index 000000000..d32b0fe65 --- /dev/null +++ b/src/utils/twMerge.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} 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', diff --git a/yarn.lock b/yarn.lock index 599e62c91..75bba2e55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18,6 +18,14 @@ "@csstools/css-tokenizer" "^3.0.3" lru-cache "^10.4.3" +"@assembly-js/node-sdk@^3.19.1": + version "3.19.1" + resolved "https://registry.yarnpkg.com/@assembly-js/node-sdk/-/node-sdk-3.19.1.tgz#3d9ff79782250e9ee7f0afc43d050d205afdb3f8" + integrity sha512-8td1ks3Oj3aPmFGUXdoTC28fEH3t5keyDt4vTewfOBSZW3sW0Rxw7LP9oP4clw29KIeLQtM5BTpsP3I5VM0edw== + dependencies: + isomorphic-fetch "^3.0.0" + next "^14.0.2" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" @@ -4894,9 +4902,9 @@ copilot-design-system@^2.3.4: uuid "^9.0.1" copilot-node-sdk@^3.16.0: - version "3.16.0" - resolved "https://registry.yarnpkg.com/copilot-node-sdk/-/copilot-node-sdk-3.16.0.tgz#c1335c768bd3f9d091c154f930fce9a2633ade8d" - integrity sha512-wqqeJuW+uge39nC1/+cqMWSSgCCNThtCR+JykPYFB2PTj0d0tfROCXofwtqZZccUUzk/xXXkJF53QfweVxmMLg== + version "3.19.1" + resolved "https://registry.yarnpkg.com/copilot-node-sdk/-/copilot-node-sdk-3.19.1.tgz#4467873d003379dac4db57a99a620685b8ae2cd9" + integrity sha512-NV5jS50wMxAf+327fPjoizOJgC/+uaENqAJjjofurD38pnLKp5PpVjru02L4wBQOL0le9EaU59VE9tLmjmS4+Q== dependencies: isomorphic-fetch "^3.0.0" next "^14.0.2" @@ -9520,6 +9528,11 @@ tailwind-merge@^2.3.0: resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5" integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA== +tailwind-merge@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz#5a264e131a096879965f1175d11f8c36e6b64eca" + integrity sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g== + tailwindcss@^3.3.0: version "3.4.17" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63"