diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index e19e86685..52c655c8f 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -5,6 +5,7 @@ import { Get, Param, Post, + Put, Query, UseFilters, } from '@nestjs/common'; @@ -48,6 +49,37 @@ export class IntegrationsController { return this._integrationManager.getAllIntegrations(); } + @Get('/customers') + getCustomers(@GetOrgFromRequest() org: Organization) { + return this._integrationService.customers(org.id); + } + + @Put('/:id/group') + async updateIntegrationGroup( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string, + @Body() body: { group: string } + ) { + return this._integrationService.updateIntegrationGroup( + org.id, + id, + body.group + ); + } + + @Put('/:id/customer-name') + async updateOnCustomerName( + @GetOrgFromRequest() org: Organization, + @Param('id') id: string, + @Body() body: { name: string } + ) { + return this._integrationService.updateOnCustomerName( + org.id, + id, + body.name + ); + } + @Get('/list') async getIntegrationList(@GetOrgFromRequest() org: Organization) { return { @@ -71,6 +103,7 @@ export class IntegrationsController { time: JSON.parse(p.postingTimes), changeProfilePicture: !!findIntegration?.changeProfilePicture, changeNickName: !!findIntegration?.changeNickname, + customer: p.customer, }; }), }; diff --git a/apps/frontend/src/app/global.scss b/apps/frontend/src/app/global.scss index ac18deaa6..885b1e7d3 100644 --- a/apps/frontend/src/app/global.scss +++ b/apps/frontend/src/app/global.scss @@ -384,3 +384,14 @@ div div .set-font-family { font-style: normal !important; font-weight: 400 !important; } + +.col-calendar:hover:before { + content: "Date passed"; + color: white; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + white-space: nowrap; + opacity: 30%; +} \ No newline at end of file diff --git a/apps/frontend/src/components/launches/add.edit.model.tsx b/apps/frontend/src/components/launches/add.edit.model.tsx index 807d249b2..38c434def 100644 --- a/apps/frontend/src/components/launches/add.edit.model.tsx +++ b/apps/frontend/src/components/launches/add.edit.model.tsx @@ -50,6 +50,8 @@ import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import Image from 'next/image'; import { weightedLength } from '@gitroom/helpers/utils/count.length'; +import { uniqBy } from 'lodash'; +import { Select } from '@gitroom/react/form/select'; function countCharacters(text: string, type: string): number { if (type !== 'x') { @@ -65,17 +67,36 @@ export const AddEditModal: FC<{ reopenModal: () => void; mutate: () => void; }> = (props) => { - const { date, integrations, reopenModal, mutate } = props; - const [dateState, setDateState] = useState(date); - - // hook to open a new modal - const modal = useModals(); + const { date, integrations: ints, reopenModal, mutate } = props; + const [customer, setCustomer] = useState(''); // selected integrations to allow edit const [selectedIntegrations, setSelectedIntegrations] = useStateCallback< Integrations[] >([]); + const integrations = useMemo(() => { + if (!customer) { + return ints; + } + + const list = ints.filter((f) => f?.customer?.id === customer); + if (list.length === 1) { + setSelectedIntegrations([list[0]]); + } + + return list; + }, [customer, ints]); + + const totalCustomers = useMemo(() => { + return uniqBy(ints, (i) => i?.customer?.id).length; + }, [ints]); + + const [dateState, setDateState] = useState(date); + + // hook to open a new modal + const modal = useModals(); + // value of each editor const [value, setValue] = useState< Array<{ @@ -286,11 +307,12 @@ export const AddEditModal: FC<{ } if ( - key.value.some( - (p) => { - return countCharacters(p.content, key?.integration?.identifier || '') > (key.maximumCharacters || 1000000); - } - ) + key.value.some((p) => { + return ( + countCharacters(p.content, key?.integration?.identifier || '') > + (key.maximumCharacters || 1000000) + ); + }) ) { if ( !(await deleteDialog( @@ -417,6 +439,26 @@ export const AddEditModal: FC<{ information={data} onChange={setPostFor} /> + {totalCustomers > 1 && ( + + )} diff --git a/apps/frontend/src/components/launches/calendar.context.tsx b/apps/frontend/src/components/launches/calendar.context.tsx index 1bad55b01..574981f41 100644 --- a/apps/frontend/src/components/launches/calendar.context.tsx +++ b/apps/frontend/src/components/launches/calendar.context.tsx @@ -62,6 +62,10 @@ export interface Integrations { changeProfilePicture: boolean; changeNickName: boolean; time: { time: number }[]; + customer?: { + name?: string; + id?: string; + } } function getWeekNumber(date: Date) { diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 91c016b4c..984a57c9c 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -13,7 +13,6 @@ import clsx from 'clsx'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { ExistingDataContextProvider } from '@gitroom/frontend/components/launches/helpers/use.existing.data'; import { useDrag, useDrop } from 'react-dnd'; -import { DNDProvider } from '@gitroom/frontend/components/launches/helpers/dnd.provider'; import { Integration, Post, State } from '@prisma/client'; import { useAddProvider } from '@gitroom/frontend/components/launches/add.provider.component'; import { CommentComponent } from '@gitroom/frontend/components/launches/comments/comment.component'; @@ -33,11 +32,11 @@ extend(isSameOrBefore); const convertTimeFormatBasedOnLocality = (time: number) => { if (isUSCitizen()) { - return `${time === 12 ? 12 : time%12}:00 ${time >= 12 ? "PM" : "AM"}` + return `${time === 12 ? 12 : time % 12}:00 ${time >= 12 ? 'PM' : 'AM'}`; } else { - return `${time}:00` + return `${time}:00`; } -} +}; export const days = [ 'Monday', @@ -100,7 +99,7 @@ export const DayView = () => { .startOf('day') .add(option[0].time, 'minute') .local() - .format(isUSCitizen() ? "hh:mm A": "HH:mm")} + .format(isUSCitizen() ? 'hh:mm A' : 'HH:mm')}
{ const { display } = useCalendar(); return ( - + <> {display === 'day' ? ( ) : display === 'week' ? ( @@ -249,7 +248,7 @@ export const Calendar = () => { ) : ( )} - + ); }; @@ -443,8 +442,9 @@ export const CalendarColumn: FC<{ )}
diff --git a/apps/frontend/src/components/launches/customer.modal.tsx b/apps/frontend/src/components/launches/customer.modal.tsx new file mode 100644 index 000000000..109632250 --- /dev/null +++ b/apps/frontend/src/components/launches/customer.modal.tsx @@ -0,0 +1,88 @@ +import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { useModals } from '@mantine/modals'; +import { Integration } from '@prisma/client'; +import { Autocomplete } from '@mantine/core'; +import useSWR from 'swr'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { Button } from '@gitroom/react/form/button'; + +export const CustomerModal: FC<{ + integration: Integration & { customer?: { id: string; name: string } }; + onClose: () => void; +}> = (props) => { + const fetch = useFetch(); + const { onClose, integration } = props; + const [customer, setCustomer] = useState( + integration.customer?.name || undefined + ); + const modal = useModals(); + + const loadCustomers = useCallback(async () => { + return (await fetch('/integrations/customers')).json(); + }, []); + + const removeFromCustomer = useCallback(async () => { + saveCustomer(true); + }, []); + + const saveCustomer = useCallback(async (removeCustomer?: boolean) => { + if (!customer) { + return; + } + + await fetch(`/integrations/${integration.id}/customer-name`, { + method: 'PUT', + body: JSON.stringify({ name: removeCustomer ? '' : customer }), + }); + + modal.closeAll(); + onClose(); + }, [customer]); + + const { data } = useSWR('/customers', loadCustomers); + + return ( +
+ + + +
+ p.name) || []} + /> +
+ +
+ + {!!integration?.customer?.name && } +
+
+ ); +}; diff --git a/apps/frontend/src/components/launches/launches.component.tsx b/apps/frontend/src/components/launches/launches.component.tsx index b03566b34..60884a503 100644 --- a/apps/frontend/src/components/launches/launches.component.tsx +++ b/apps/frontend/src/components/launches/launches.component.tsx @@ -1,9 +1,9 @@ 'use client'; import { AddProviderButton } from '@gitroom/frontend/components/launches/add.provider.component'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import Image from 'next/image'; -import { orderBy } from 'lodash'; +import { groupBy, orderBy } from 'lodash'; import { CalendarWeekProvider } from '@gitroom/frontend/components/launches/calendar.context'; import { Filters } from '@gitroom/frontend/components/launches/filters'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; @@ -18,7 +18,201 @@ import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events'; import { Calendar } from './calendar'; +import { useDrag, useDrop } from 'react-dnd'; +import { DNDProvider } from '@gitroom/frontend/components/launches/helpers/dnd.provider'; +interface MenuComponentInterface { + refreshChannel: ( + integration: Integration & { identifier: string } + ) => () => void; + continueIntegration: (integration: Integration) => () => void; + totalNonDisabledChannels: number; + mutate: (shouldReload?: boolean) => void; + update: (shouldReload: boolean) => void; +} + +export const MenuGroupComponent: FC< + MenuComponentInterface & { + changeItemGroup: (id: string, group: string) => void; + group: { + id: string; + name: string; + values: Array< + Integration & { + identifier: string; + changeProfilePicture: boolean; + changeNickName: boolean; + } + >; + }; + } +> = (props) => { + const { + group, + mutate, + update, + continueIntegration, + totalNonDisabledChannels, + refreshChannel, + changeItemGroup, + } = props; + + const [collectedProps, drop] = useDrop(() => ({ + accept: 'menu', + drop: (item: { id: string }, monitor) => { + changeItemGroup(item.id, group.id); + }, + collect: (monitor) => ({ + isOver: !!monitor.isOver(), + }), + })); + + return ( +
+ {collectedProps.isOver && ( +
+
+
+
+
+ )} + {!!group.name &&
{group.name}
} + {group.values.map((integration) => ( + + ))} +
+ ); +}; +export const MenuComponent: FC< + MenuComponentInterface & { + integration: Integration & { + identifier: string; + changeProfilePicture: boolean; + changeNickName: boolean; + }; + } +> = (props) => { + const { + totalNonDisabledChannels, + continueIntegration, + refreshChannel, + mutate, + update, + integration, + } = props; + + const user = useUser(); + const [collected, drag, dragPreview] = useDrag(() => ({ + type: 'menu', + item: { id: integration.id }, + })); + + return ( +
+
+ {(integration.inBetweenSteps || integration.refreshNeeded) && ( +
+
+ ! +
+
+
+ )} + + {integration.identifier === 'youtube' ? ( + + ) : ( + {integration.identifier} + )} +
+
+ {integration.name} +
+ totalNonDisabledChannels && + integration.disabled + } + canDisable={!integration.disabled} + /> +
+ ); +}; export const LaunchesComponent = () => { const fetch = useFetch(); const router = useRouter(); @@ -30,7 +224,6 @@ export const LaunchesComponent = () => { const load = useCallback(async (path: string) => { return (await (await fetch(path)).json()).integrations; }, []); - const user = useUser(); const { isLoading, @@ -47,6 +240,28 @@ export const LaunchesComponent = () => { ); }, [integrations]); + const changeItemGroup = useCallback( + async (id: string, group: string) => { + mutate( + integrations.map((integration: any) => { + if (integration.id === id) { + return { ...integration, customer: { id: group } }; + } + return integration; + }), + false + ); + + await fetch(`/integrations/${id}/group`, { + method: 'PUT', + body: JSON.stringify({ group }), + }); + + mutate(); + }, + [integrations] + ); + const sortedIntegrations = useMemo(() => { return orderBy( integrations, @@ -55,6 +270,25 @@ export const LaunchesComponent = () => { ); }, [integrations]); + const menuIntegrations = useMemo(() => { + return orderBy( + Object.values( + groupBy(sortedIntegrations, (o) => o?.customer?.id || '') + ).map((p) => ({ + name: (p[0].customer?.name || '') as string, + id: (p[0].customer?.id || '') as string, + isEmpty: p.length === 0, + values: orderBy( + p, + ['type', 'disabled', 'identifier'], + ['desc', 'asc', 'asc'] + ), + })), + ['isEmpty', 'name'], + ['desc', 'asc'] + ); + }, [sortedIntegrations]); + const update = useCallback(async (shouldReload: boolean) => { if (shouldReload) { setReload(true); @@ -112,114 +346,41 @@ export const LaunchesComponent = () => { // @ts-ignore return ( - -
-
-
-
-

Channels

-
- {sortedIntegrations.length === 0 && ( -
No channels
- )} - {sortedIntegrations.map((integration) => ( -
-
- {(integration.inBetweenSteps || - integration.refreshNeeded) && ( -
-
- ! -
-
-
- )} - - {integration.identifier === 'youtube' ? ( - - ) : ( - {integration.identifier} - )} -
-
- {integration.name} -
- + +
+
+
+
+

Channels

+
+ {sortedIntegrations.length === 0 && ( +
No channels
+ )} + {menuIntegrations.map((menu) => ( + totalNonDisabledChannels && - integration.disabled - } - canDisable={!integration.disabled} + continueIntegration={continueIntegration} + update={update} + refreshChannel={refreshChannel} + totalNonDisabledChannels={totalNonDisabledChannels} /> -
- ))} + ))} +
+ update(true)} /> + {/*{sortedIntegrations?.length > 0 && user?.tier?.ai && }*/} +
+
+ +
- update(true)} /> - {/*{sortedIntegrations?.length > 0 && user?.tier?.ai && }*/} -
-
- -
-
- + + ); }; diff --git a/apps/frontend/src/components/launches/menu/menu.tsx b/apps/frontend/src/components/launches/menu/menu.tsx index d6f6b2276..7995e5d2b 100644 --- a/apps/frontend/src/components/launches/menu/menu.tsx +++ b/apps/frontend/src/components/launches/menu/menu.tsx @@ -8,6 +8,7 @@ import { useModals } from '@mantine/modals'; import { TimeTable } from '@gitroom/frontend/components/launches/time.table'; import { useCalendar } from '@gitroom/frontend/components/launches/calendar.context'; import { BotPicture } from '@gitroom/frontend/components/launches/bot.picture'; +import { CustomerModal } from '@gitroom/frontend/components/launches/customer.modal'; export const Menu: FC<{ canEnable: boolean; @@ -36,10 +37,13 @@ export const Menu: FC<{ setShow(false); }); - const changeShow: MouseEventHandler = useCallback((e) => { - e.stopPropagation(); - setShow(!show); - }, [show]); + const changeShow: MouseEventHandler = useCallback( + (e) => { + e.stopPropagation(); + setShow(!show); + }, + [show] + ); const disableChannel = useCallback(async () => { if ( @@ -139,6 +143,34 @@ export const Menu: FC<{ setShow(false); }, [integrations]); + const addToCustomer = useCallback(() => { + const findIntegration = integrations.find( + (integration) => integration.id === id + ); + + modal.openModal({ + classNames: { + modal: 'w-[100%] max-w-[600px] bg-transparent text-textColor', + }, + size: '100%', + withCloseButton: false, + closeOnEscape: true, + closeOnClickOutside: true, + children: ( + { + mutate(); + toast.show('Customer Updated', 'success'); + }} + /> + ), + }); + + setShow(false); + }, [integrations]); + return (
)} +
+
+ + + +
+
Move / add to customer
+
, + private _customers: PrismaRepository<'customer'>, private _posts: PrismaRepository<'post'> ) {} @@ -215,12 +216,79 @@ export class IntegrationRepository { return integration?.integration; } + async updateOnCustomerName(org: string, id: string, name: string) { + const customer = !name + ? undefined + : (await this._customers.model.customer.findFirst({ + where: { + orgId: org, + name, + }, + })) || + (await this._customers.model.customer.create({ + data: { + name, + orgId: org, + }, + })); + + return this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: { + customer: !customer + ? { disconnect: true } + : { + connect: { + id: customer.id, + }, + }, + }, + }); + } + + updateIntegrationGroup(org: string, id: string, group: string) { + return this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: !group + ? { + customer: { + disconnect: true, + }, + } + : { + customer: { + connect: { + id: group, + }, + }, + }, + }); + } + + customers(orgId: string) { + return this._customers.model.customer.findMany({ + where: { + orgId, + deletedAt: null, + }, + }); + } + getIntegrationsList(org: string) { return this._integration.model.integration.findMany({ where: { organizationId: org, deletedAt: null, }, + include: { + customer: true, + }, }); } diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index 5302a30aa..c9687bdca 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -66,6 +66,14 @@ export class IntegrationService { ); } + updateIntegrationGroup(org: string, id: string, group: string) { + return this._integrationRepository.updateIntegrationGroup(org, id, group); + } + + updateOnCustomerName(org: string, id: string, name: string) { + return this._integrationRepository.updateOnCustomerName(org, id, name); + } + getIntegrationsList(org: string) { return this._integrationRepository.getIntegrationsList(org); } @@ -362,4 +370,8 @@ export class IntegrationService { return []; } + + customers(orgId: string) { + return this._integrationRepository.customers(orgId); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index a28583301..7cd6ac59f 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -29,6 +29,7 @@ model Organization { buyerOrganization MessagesGroup[] usedCodes UsedCodes[] credits Credits[] + customers Customer[] } model User { @@ -241,6 +242,19 @@ model Subscription { @@index([deletedAt]) } +model Customer { + id String @id @default(uuid()) + name String + orgId String + organization Organization @relation(fields: [orgId], references: [id]) + integrations Integration[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@unique([orgId, name, deletedAt]) +} + model Integration { id String @id @default(cuid()) internalId String @@ -264,6 +278,8 @@ model Integration { refreshNeeded Boolean @default(false) postingTimes String @default("[{\"time\":120}, {\"time\":400}, {\"time\":700}]") customInstanceDetails String? + customerId String? + customer Customer? @relation(fields: [customerId], references: [id]) @@index([updatedAt]) @@index([deletedAt])