diff --git a/.eslintrc.js b/.eslintrc.js index 652c3bc0..e410b435 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -37,7 +37,10 @@ module.exports = { { files: ['./apps/web/**/*'], extends: ['next/core-web-vitals'], - rules: { 'react/no-children-prop': ['warn', { allowFunctions: true }] } + rules: { + 'react/no-children-prop': ['warn', { allowFunctions: true }], + '@next/next/no-img-element': 'off' + } } ] }; diff --git a/apps/mail-bridge/postal-routes/inbound.ts b/apps/mail-bridge/postal-routes/inbound.ts index b1ec9c9d..b6655c16 100644 --- a/apps/mail-bridge/postal-routes/inbound.ts +++ b/apps/mail-bridge/postal-routes/inbound.ts @@ -6,6 +6,12 @@ import type { Ctx } from '../ctx'; export const inboundApi = createHonoApp(); +// Add logging middleware +inboundApi.use('*', async (c, next) => { + console.info(`Incoming request: ${c.req.method} ${c.req.url}`); + await next(); +}); + inboundApi.post( '/mail/inbound/:orgId/:mailserverId', zValidator('json', postalMessageSchema), diff --git a/apps/mail-bridge/queue/mail-processor.ts b/apps/mail-bridge/queue/mail-processor.ts index 0f319082..23b7c580 100644 --- a/apps/mail-bridge/queue/mail-processor.ts +++ b/apps/mail-bridge/queue/mail-processor.ts @@ -6,6 +6,7 @@ import { convoEntryReplies, convoParticipants, convoSubjects, + convoToSpaces, convos, emailIdentities, orgs, @@ -22,12 +23,12 @@ import { createExtensionSet } from '@u22n/tiptap/extensions'; import { sendRealtimeNotification } from '../utils/realtime'; import { simpleParser, type EmailAddress } from 'mailparser'; import { parseAddressIds } from '../utils/contactParsing'; +import { addConvoToSpace } from '../utils/spaceUtils'; import { eq, and, inArray } from '@u22n/database/orm'; import { tiptapCore, tiptapHtml } from '@u22n/tiptap'; import { typeIdValidator } from '@u22n/utils/typeid'; import { getTracer } from '@u22n/otel/helpers'; import { parseMessage } from '@u22n/mailtools'; -import { discord } from '@u22n/utils/discord'; import { logger } from '@u22n/otel/logger'; import { sanitize } from '../utils/purify'; import { db } from '@u22n/database'; @@ -106,6 +107,7 @@ async function resolveOrgAndMailserver({ mailserverId !== 'fwd' ) { //verify the mailserver actually exists + console.error('test42 - inputs 🔥', { orgId, mailserverId }); const mailServer = await db.query.postalServers.findFirst({ where: eq(postalServers.publicId, mailserverId), columns: { @@ -120,13 +122,17 @@ async function resolveOrgAndMailserver({ } } }); - if (!mailServer || mailServer.orgId !== orgId) + console.error('test42 db response 🔥', { mailServer }); + + if (!mailServer || mailServer.orgId !== orgId) { + console.error('test42 trow 🫡'); throw new Error( `Mailserver not found or does not belong to the org ${JSON.stringify({ orgId, mailserverId })}` ); + } return { orgId: mailServer.orgId, @@ -292,6 +298,7 @@ export const worker = createWorker( (job) => tracer.startActiveSpan('Mail Processor', async (span) => { try { + console.info('Starting mail processor job', { jobId: job.id }); span?.setAttributes({ 'job.id': job.id, 'job.data': JSON.stringify(job.data) @@ -300,12 +307,18 @@ export const worker = createWorker( const { rawMessage, params } = job.data; const { id, rcpt_to, message, base64 } = rawMessage; + console.info('Resolving org and mailserver', { rcpt_to, ...params }); const { orgId, orgPublicId, forwardedEmailAddress } = await resolveOrgAndMailserver({ rcpt_to, ...params }); + console.info('Resolved org and mailserver', { + orgId, + orgPublicId, + forwardedEmailAddress + }); span?.addEvent('mail-processor.resolved_org_mailserver', { orgId, orgPublicId, @@ -345,6 +358,8 @@ export const worker = createWorker( : parsedEmail.cc.value : []; + console.info(parsedEmail); + const inReplyToEmailId = parsedEmail.inReplyTo ? (parsedEmail.inReplyTo .split(/\s+/g) // split by whitespace @@ -383,7 +398,6 @@ export const worker = createWorker( }); if (alreadyProcessedMessageWithThisId) { - logger.warn('Message already processed'); return; } @@ -407,7 +421,11 @@ export const worker = createWorker( cleanStyles: true }); - //* get the contact and emailIdentityIds for the message + console.info('Parsing email addresses', { + messageFrom, + messageTo, + messageCc + }); const [ messageToPlatformObject, messageFromPlatformObject, @@ -432,6 +450,12 @@ export const worker = createWorker( : Promise.resolve([]) ]); + console.info('Email addresses parsed', { + messageToPlatformObject, + messageFromPlatformObject, + messageCcPlatformObject + }); + if (!messageToPlatformObject?.[0] || !messageFromPlatformObject?.[0]) { span?.setAttributes({ 'message.toObject': JSON.stringify(messageToPlatformObject), @@ -454,6 +478,7 @@ export const worker = createWorker( signaturePlainText: true } }); + // TODO: if contact has no avatar timestamp, or timestamp older than 28 days, fetch the avatar from UnAvatar if (!contact) { throw new Error('No contact found for from address'); @@ -522,8 +547,7 @@ export const worker = createWorker( with: { destinations: { columns: { - teamId: true, - orgMemberId: true + spaceId: true } } } @@ -531,25 +555,19 @@ export const worker = createWorker( } }); - const { routingRuleOrgMemberIds, routingRuleTeamIds } = - emailIdentityResponse.reduce( - (values, { routingRules }) => { - for (const destination of routingRules.destinations) { - if (destination.teamId) { - values.routingRuleTeamIds.push(destination.teamId); - } else if (destination.orgMemberId) { - values.routingRuleOrgMemberIds.push(destination.orgMemberId); - } + const routingRuleSpaceIds = [] as number[]; + emailIdentityResponse.map((ei) => { + if (ei.routingRules.destinations.length > 0) { + ei.routingRules.destinations.map((dr) => { + if (dr.spaceId) { + routingRuleSpaceIds.push(dr.spaceId); } - return values; - }, - { - routingRuleTeamIds: [] as number[], - routingRuleOrgMemberIds: [] as number[] - } - ); + }); + } + }); //* start to process the conversation + console.info('Processing conversation', { inReplyToEmailId }); let hasReplyToButIsNewConvo: boolean | null = null; let convoId = -1; let replyToId: number | null = null; @@ -572,6 +590,9 @@ export const worker = createWorker( // - if no, then we assume this is a new convo and handle it at such if (inReplyToEmailId) { + console.info('Checking for existing message with inReplyToEmailId', { + inReplyToEmailId + }); const existingMessage = await db.query.convoEntries.findFirst({ where: and( eq(convoEntries.orgId, orgId), @@ -591,9 +612,7 @@ export const worker = createWorker( participants: { columns: { id: true, - contactId: true, - teamId: true, - orgMemberId: true + contactId: true } } } @@ -607,6 +626,8 @@ export const worker = createWorker( } }); + console.info('Existing message query result', { existingMessage }); + if (existingMessage) { hasReplyToButIsNewConvo = false; convoId = existingMessage.convoId; @@ -648,16 +669,6 @@ export const worker = createWorker( const missingContacts = contactIds.filter( (c) => !existingConvoParticipantsContactIds.includes(c) ); - const existingConvoParticipantsOrgMemberIds = - existingMessage.convo.participants.map((p) => p.orgMemberId); - const missingOrgMembers = routingRuleOrgMemberIds.filter( - (c) => !existingConvoParticipantsOrgMemberIds.includes(c) - ); - const existingConvoParticipantsTeamIds = - existingMessage.convo.participants.map((p) => p.teamId); - const missingUserTeams = routingRuleTeamIds.filter( - (c) => !existingConvoParticipantsTeamIds.includes(c) - ); // - if not, then add them to the convo participants to add array if (missingContacts.length) { @@ -671,34 +682,66 @@ export const worker = createWorker( })) ); } - if (missingOrgMembers.length) { - convoParticipantsToAdd.push( - ...missingOrgMembers.map((orgMemberId) => ({ - convoId, - orgMemberId, - orgId, - publicId: typeIdGenerator('convoParticipants'), - role: 'contributor' as const - })) - ); - } - if (missingUserTeams.length) { - convoParticipantsToAdd.push( - ...missingUserTeams.map((teamId) => ({ - convoId, - teamId, - orgId, - publicId: typeIdGenerator('convoParticipants'), - role: 'contributor' as const - })) - ); + + // check all the spaces the convo is meant to be in, and add it to missing spaces via the convoToSpaces table + + const existingConvoToSpacesEntries = + await db.query.convoToSpaces.findMany({ + where: and( + eq(convoToSpaces.orgId, orgId), + eq(convoToSpaces.convoId, Number(convoId)) + ), + columns: { + spaceId: true + } + }); + + const existingSpaceIds = existingConvoToSpacesEntries.map( + (entry) => entry.spaceId + ); + // remove the existing spaceIds from the routingRuleSpaceIds + const missingSpaceIds = routingRuleSpaceIds.filter( + (spaceId) => !existingSpaceIds.includes(spaceId) + ); + + // add convo to the missing spaces + // type ConvoToSpaceInsertDbType = typeof convoToSpaces.$inferInsert; + // const convoToSpacesInsertValuesArray: ConvoToSpaceInsertDbType[] = + // []; + // missingSpaceIds.forEach((spaceId) => { + // convoToSpacesInsertValuesArray.push({ + // orgId: orgId, + // convoId: Number(convoId), + // spaceId: spaceId, + // publicId: typeIdGenerator('convoToSpaces') + // }); + // }); + // await db + // .insert(convoToSpaces) + // .values(convoToSpacesInsertValuesArray); + + for (const spaceId of missingSpaceIds) { + await addConvoToSpace({ + db, + orgId, + convoId: Number(convoId), + spaceId: spaceId + }); } } else { - // if there is a reply to header but we cant find the conversation, we handle this like its a new convo + console.info( + 'No existing message found, treating as new conversation' + ); hasReplyToButIsNewConvo = true; } } + console.info('Creating new conversation or adding to existing', { + isNewConvo: !inReplyToEmailId || hasReplyToButIsNewConvo, + convoId, + subjectId + }); + // create a new convo with new participants if (!inReplyToEmailId || hasReplyToButIsNewConvo) { const newConvoInsert = await db.insert(convos).values({ @@ -730,28 +773,20 @@ export const worker = createWorker( })) ); } - if (routingRuleOrgMemberIds.length) { - convoParticipantsToAdd.push( - ...routingRuleOrgMemberIds.map((orgMemberId) => ({ - convoId, - orgMemberId, - orgId, - publicId: typeIdGenerator('convoParticipants'), - role: 'contributor' as const - })) - ); - } - if (routingRuleTeamIds.length) { - convoParticipantsToAdd.push( - ...routingRuleTeamIds.map((teamId) => ({ - convoId: convoId, - teamId: teamId, - orgId: orgId, - publicId: typeIdGenerator('convoParticipants'), - role: 'contributor' as const - })) - ); - } + + type ConvoToSpaceInsertDbType = typeof convoToSpaces.$inferInsert; + + const convoToSpacesInsertValuesArray: ConvoToSpaceInsertDbType[] = []; + routingRuleSpaceIds.forEach((spaceId) => { + convoToSpacesInsertValuesArray.push({ + orgId: orgId, + convoId: Number(convoId), + spaceId: spaceId, + publicId: typeIdGenerator('convoToSpaces') + }); + }); + + await db.insert(convoToSpaces).values(convoToSpacesInsertValuesArray); } //* start to handle creating the message in the convo @@ -762,7 +797,12 @@ export const worker = createWorker( } if (convoParticipantsToAdd.length) { + console.info('Inserting new convo participants', { + count: convoParticipantsToAdd.length + }); await db.insert(convoParticipants).values(convoParticipantsToAdd); + } else { + console.info('No new convo participants to add'); } if (!fromAddressParticipantId) { @@ -781,67 +821,71 @@ export const worker = createWorker( // @ts-expect-error we check and define earlier up fromAddressParticipantId = contactParticipant.id; } else if (fromAddressPlatformObject.type === 'emailIdentity') { + console.error( + '🚪 Adding participants from internal email identity with messages sent from external email services not supported yet' + ); + //! TODO: How do we handle adding the participant to the convo if we only track spaces? + //! leave code below for ref and quick revert if needed // we need to get the first person/team in the routing rule and add them to the convo - const emailIdentityParticipant = - await db.query.emailIdentities.findFirst({ - where: and( - eq(emailIdentities.orgId, orgId), - eq(emailIdentities.id, fromAddressPlatformObject?.id) - ), - columns: { - id: true - }, - with: { - routingRules: { - columns: { - id: true - }, - with: { - destinations: { - columns: { - teamId: true, - orgMemberId: true - } - } - } - } - } - }); - const firstDestination = - // @ts-expect-error, taken form old code, will rewrite later - emailIdentityParticipant.routingRules.destinations[0]!; - let convoParticipantFromAddressIdentity; - if (firstDestination.orgMemberId) { - convoParticipantFromAddressIdentity = - await db.query.convoParticipants.findFirst({ - where: and( - eq(convoParticipants.orgId, orgId), - eq(convoParticipants.convoId, convoId), - - eq( - convoParticipants.orgMemberId, - firstDestination.orgMemberId - ) - ), - columns: { - id: true - } - }); - } else if (firstDestination.teamId) { - convoParticipantFromAddressIdentity = - await db.query.convoParticipants.findFirst({ - where: and( - eq(convoParticipants.orgId, orgId), - eq(convoParticipants.convoId, convoId || 0), - eq(convoParticipants.teamId, firstDestination.teamId) - ), - columns: { - id: true - } - }); - } - // @ts-expect-error, taken form old code, will rewrite later - fromAddressParticipantId = convoParticipantFromAddressIdentity.id; + // const emailIdentityParticipant = + // await db.query.emailIdentities.findFirst({ + // where: and( + // eq(emailIdentities.orgId, orgId), + // eq(emailIdentities.id, fromAddressPlatformObject?.id) + // ), + // columns: { + // id: true + // }, + // with: { + // routingRules: { + // columns: { + // id: true + // }, + // with: { + // destinations: { + // columns: { + // spaceId: true, + // } + // } + // } + // } + // } + // }); + // const firstDestination = + // // @ts-expect-error, taken form old code, will rewrite later + // emailIdentityParticipant.routingRules.destinations[0]!; + // let convoParticipantFromAddressIdentity; + // if (firstDestination.orgMemberId) { + // convoParticipantFromAddressIdentity = + // await db.query.convoParticipants.findFirst({ + // where: and( + // eq(convoParticipants.orgId, orgId), + // eq(convoParticipants.convoId, convoId), + + // eq( + // convoParticipants.orgMemberId, + // firstDestination.orgMemberId + // ) + // ), + // columns: { + // id: true + // } + // }); + // } else if (firstDestination.teamId) { + // convoParticipantFromAddressIdentity = + // await db.query.convoParticipants.findFirst({ + // where: and( + // eq(convoParticipants.orgId, orgId), + // eq(convoParticipants.convoId, convoId || 0), + // eq(convoParticipants.teamId, firstDestination.teamId) + // ), + // columns: { + // id: true + // } + // }); + // } + + // fromAddressParticipantId = convoParticipantFromAddressIdentity.id; } } @@ -907,6 +951,19 @@ export const worker = createWorker( tipTapExtensions ); + // Before inserting a new convo entry, add a check to ensure we have valid data + console.info('Inserting new convo entry', { + orgId, + convoId, + fromAddressParticipantId, + replyToId, + subjectId + }); + + if (!fromAddressParticipantId) { + throw new Error('No from address participant id found'); + } + const insertNewConvoEntry = await db.insert(convoEntries).values({ orgId, publicId: typeIdGenerator('convoEntries'), @@ -914,13 +971,15 @@ export const worker = createWorker( visibility: 'all_participants', type: 'message', metadata: convoEntryMetadata, - author: fromAddressParticipantId!, + author: fromAddressParticipantId, body: convoEntryBody, bodyPlainText: convoEntryBodyPlainText, replyToId, subjectId }); + console.info('Inserted new convo entry', insertNewConvoEntry); + await db .update(convos) .set({ @@ -928,6 +987,7 @@ export const worker = createWorker( }) .where(eq(convos.id, convoId)); + console.info('Uploading attachments'); const uploadedAttachments = await Promise.allSettled( attachments.map((attachment) => uploadAndAttachAttachment( @@ -972,6 +1032,8 @@ export const worker = createWorker( }[] ); + console.info('Uploaded attachments', uploadedAttachments); + const parsedEmailMessageHtmlWithAttachments = replaceCidWithUrl( strippedEmail.parsedMessageHtml, uploadedAttachments @@ -1008,6 +1070,7 @@ export const worker = createWorker( uploadedAttachments ); + console.info('Inserting convo entry raw HTML email'); await db.insert(convoEntryRawHtmlEmails).values({ orgId: orgId, entryId: Number(insertNewConvoEntry.insertId), @@ -1016,15 +1079,19 @@ export const worker = createWorker( wipeDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 28) // 28 days }); + console.info('Sending realtime notification'); await sendRealtimeNotification({ newConvo: (!inReplyToEmailId || hasReplyToButIsNewConvo) ?? false, convoId: convoId, - convoEntryId: +insertNewConvoEntry.insertId + convoEntryId: Number(insertNewConvoEntry.insertId) }); + + console.info('Mail processor job completed successfully'); } catch (e) { - span?.recordException(e as Error); console.error('Error processing email', e); - await discord.info(`Mailbridge Queue Error\n${(e as Error).message}`); + span?.recordException(e as Error); + // Log the full error stack trace + console.error('Full error stack:', (e as Error).stack); // Throw the error to be caught by the worker, and moving to failed jobs throw e; } diff --git a/apps/mail-bridge/trpc/routers/sendMailRouter.ts b/apps/mail-bridge/trpc/routers/sendMailRouter.ts index a9d5e895..782e5e06 100644 --- a/apps/mail-bridge/trpc/routers/sendMailRouter.ts +++ b/apps/mail-bridge/trpc/routers/sendMailRouter.ts @@ -6,7 +6,6 @@ import { convoEntries, type ConvoEntryMetadataEmailAddress, contacts, - emailIdentitiesAuthorizedOrgMembers, orgMembers, teams, type ConvoEntryMetadataMissingParticipant, @@ -95,7 +94,8 @@ export const sendMailRouter = router({ publicId: true, orgMemberId: true, teamId: true, - contactId: true + contactId: true, + emailIdentityId: true }, where: inArray(convoParticipants.role, ['assigned', 'contributor']) } @@ -125,22 +125,20 @@ export const sendMailRouter = router({ (participant) => participant.teamId ); + // if now external contact (a.k.a email participants) are found, we return early + // This should not be reachable as we check on platform before calling mail-bridge, but if it is, we return success if ( convoResponse.participants.length === 0 || !contactConvoParticipants.length || contactConvoParticipants.length === 0 ) { - console.error('🚨 no contact participants found', { - orgId, - convoId, - entryId, - sendAsEmailIdentityPublicId - }); return { success: true }; } + // get the convo entry to send + const convoEntryResponse = await db.query.convoEntries.findFirst({ where: eq(convoEntries.id, entryId), columns: { @@ -157,7 +155,9 @@ export const sendMailRouter = router({ with: { author: { columns: { - orgMemberId: true + orgMemberId: true, + teamId: true, + emailIdentityId: true } }, subject: { @@ -202,7 +202,7 @@ export const sendMailRouter = router({ }; } - // remove the author from the array of orgMemberParticipants + // remove the author from the array of orgMemberParticipants to not include them twice in the sending email if (convoEntryResponse.author?.orgMemberId) { orgMemberParticipants.splice( orgMemberParticipants.findIndex( @@ -250,14 +250,11 @@ export const sendMailRouter = router({ }; } - //* Handle getting the email addresses - - // if this is a new convo, we need to pass in the particpants ID to get their email address - // if(input.newConvoToParticipantId) { - // } + //* CONVO EMAIL PARTICIPANTS SECTION + //* Handle getting the email addresses for all participants const convoMetadataFromAddress: ConvoEntryMetadataEmailAddress = { - id: +sendAsEmailIdentity.id, + id: Number(sendAsEmailIdentity.id), type: 'emailIdentity', publicId: sendAsEmailIdentity.publicId, email: `${sendAsEmailIdentity.username}@${sendAsEmailIdentity.domainName}` @@ -271,8 +268,6 @@ export const sendMailRouter = router({ const missingEmailIdentitiesWarnings: ConvoEntryMetadataMissingParticipant[] = []; - //* CONVO EMAIL PARTICIPANTS SECTION - // get the email addresses for all contacts await Promise.all( contactConvoParticipants.map(async (contactParticipant) => { @@ -316,30 +311,15 @@ export const sendMailRouter = router({ if (orgMemberParticipants.length) { await Promise.all( orgMemberParticipants.map(async (orgMemberParticipant) => { - const emailIdentityResponse = - await db.query.emailIdentitiesAuthorizedOrgMembers.findFirst({ - where: and( - eq( - emailIdentitiesAuthorizedOrgMembers.orgMemberId, - orgMemberParticipant.orgMemberId! - ), - eq(emailIdentitiesAuthorizedOrgMembers.default, true) - ), - columns: { - id: true - }, - with: { - emailIdentity: { - columns: { - id: true, - publicId: true, - username: true, - domainName: true - } - } - } - }); - if (!emailIdentityResponse) { + // get the orgMembers default email identity + const orgMemberQueryResponse = await db.query.orgMembers.findFirst({ + where: eq(orgMembers.id, orgMemberParticipant.orgMemberId!), + columns: { + defaultEmailIdentityId: true + } + }); + + if (!orgMemberQueryResponse?.defaultEmailIdentityId) { const memberProfile = await db.query.orgMembers.findFirst({ where: eq(orgMembers.id, orgMemberParticipant.orgMemberId!), columns: { @@ -363,27 +343,44 @@ export const sendMailRouter = router({ return; } } + + const emailIdentityResponse = + await db.query.emailIdentities.findFirst({ + //! FIX THIS IS DIRTY SHEBANG USAGE !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + where: eq( + emailIdentities.id, + orgMemberQueryResponse!.defaultEmailIdentityId! + ), + columns: { + id: true, + publicId: true, + username: true, + domainName: true, + sendName: true + } + }); + if (emailIdentityResponse) { if ( orgMemberParticipant.publicId === input.newConvoToParticipantPublicId ) { convoMetadataToAddress = { - id: Number(emailIdentityResponse.emailIdentity.id), + id: Number(emailIdentityResponse.id), type: 'emailIdentity', - publicId: emailIdentityResponse.emailIdentity.publicId, - email: `${emailIdentityResponse.emailIdentity.username}@${emailIdentityResponse.emailIdentity.domainName}` + publicId: emailIdentityResponse.publicId, + email: `${emailIdentityResponse.username}@${emailIdentityResponse.domainName}` }; - convoToAddress = `${emailIdentityResponse.emailIdentity.username}@${emailIdentityResponse.emailIdentity.domainName}`; + convoToAddress = `${emailIdentityResponse.username}@${emailIdentityResponse.domainName}`; } else { convoMetadataCcAddresses.push({ - id: Number(emailIdentityResponse.emailIdentity.id), + id: Number(emailIdentityResponse.id), type: 'emailIdentity', - publicId: emailIdentityResponse.emailIdentity.publicId, - email: `${emailIdentityResponse.emailIdentity.username}@${emailIdentityResponse.emailIdentity.domainName}` + publicId: emailIdentityResponse.publicId, + email: `${emailIdentityResponse.username}@${emailIdentityResponse.domainName}` }); convoCcAddresses.push( - `${emailIdentityResponse.emailIdentity.username}@${emailIdentityResponse.emailIdentity.domainName}` + `${emailIdentityResponse.username}@${emailIdentityResponse.domainName}` ); } } @@ -395,29 +392,62 @@ export const sendMailRouter = router({ if (teamParticipants.length) { await Promise.all( teamParticipants.map(async (teamParticipant) => { + const teamQueryResponse = await db.query.teams.findFirst({ + where: eq(teams.id, teamParticipant.teamId!), + columns: { + name: true, + defaultEmailIdentityId: true, + publicId: true + } + }); + + if (!teamQueryResponse?.defaultEmailIdentityId) { + missingEmailIdentitiesWarnings.push({ + type: 'team', + publicId: teamParticipant.publicId, + name: teamQueryResponse?.name ?? 'unknown team' + }); + return; + } + // const emailIdentityResponse = + // await db.query.emailIdentitiesAuthorizedSenders.findFirst({ + // where: and( + // eq( + // emailIdentitiesAuthorizedSenders.teamId, + // teamParticipant.teamId! + // ), + // eq(emailIdentitiesAuthorizedSenders.default, true) + // ), + // columns: { + // id: true + // }, + // with: { + // emailIdentity: { + // columns: { + // id: true, + // publicId: true, + // username: true, + // domainName: true + // } + // } + // } + // }); + const emailIdentityResponse = - await db.query.emailIdentitiesAuthorizedOrgMembers.findFirst({ - where: and( - eq( - emailIdentitiesAuthorizedOrgMembers.teamId, - teamParticipant.teamId! - ), - eq(emailIdentitiesAuthorizedOrgMembers.default, true) + await db.query.emailIdentities.findFirst({ + where: eq( + emailIdentities.id, + teamQueryResponse.defaultEmailIdentityId ), columns: { - id: true - }, - with: { - emailIdentity: { - columns: { - id: true, - publicId: true, - username: true, - domainName: true - } - } + id: true, + publicId: true, + username: true, + domainName: true, + sendName: true } }); + if (!emailIdentityResponse) { const orgTeamResponse = await db.query.teams.findFirst({ where: eq(teams.id, teamParticipant.teamId!), @@ -436,26 +466,27 @@ export const sendMailRouter = router({ return; } } + if (emailIdentityResponse) { if ( teamParticipant.publicId === input.newConvoToParticipantPublicId ) { convoMetadataToAddress = { - id: Number(emailIdentityResponse.emailIdentity.id), + id: Number(emailIdentityResponse.id), type: 'emailIdentity', - publicId: emailIdentityResponse.emailIdentity.publicId, - email: `${emailIdentityResponse.emailIdentity.username}@${emailIdentityResponse.emailIdentity.domainName}` + publicId: emailIdentityResponse.publicId, + email: `${emailIdentityResponse.username}@${emailIdentityResponse.domainName}` }; - convoToAddress = `${emailIdentityResponse.emailIdentity.username}@${emailIdentityResponse.emailIdentity.domainName}`; + convoToAddress = `${emailIdentityResponse.username}@${emailIdentityResponse.domainName}`; } else { convoMetadataCcAddresses.push({ id: Number(emailIdentityResponse.id), type: 'emailIdentity', - publicId: emailIdentityResponse.emailIdentity.publicId, - email: `${emailIdentityResponse.emailIdentity.username}@${emailIdentityResponse.emailIdentity.domainName}` + publicId: emailIdentityResponse.publicId, + email: `${emailIdentityResponse.username}@${emailIdentityResponse.domainName}` }); convoCcAddresses.push( - `${emailIdentityResponse.emailIdentity.username}@${emailIdentityResponse.emailIdentity.domainName}` + `${emailIdentityResponse.username}@${emailIdentityResponse.domainName}` ); } } diff --git a/apps/mail-bridge/utils/realtime.ts b/apps/mail-bridge/utils/realtime.ts index 5d9d9f8b..889da79b 100644 --- a/apps/mail-bridge/utils/realtime.ts +++ b/apps/mail-bridge/utils/realtime.ts @@ -1,7 +1,7 @@ -import { convoEntries, convoParticipants, convos } from '@u22n/database/schema'; +import { convoEntries, convos } from '@u22n/database/schema'; import RealtimeServer from '@u22n/realtime/server'; -import { eq, inArray } from '@u22n/database/orm'; import type { TypeId } from '@u22n/utils/typeid'; +import { eq } from '@u22n/database/orm'; import { db } from '@u22n/database'; import { env } from '../env'; @@ -42,35 +42,49 @@ export async function sendRealtimeNotification({ } } } + }, + spaces: { + columns: {}, + with: { + space: { + columns: { + publicId: true + } + } + } } } }); - if (!convoQuery) { - return; - } + if (!convoQuery) return; const convoPublicId = convoQuery.publicId; - const orgMembersForNotificationPublicIds: TypeId<'orgMembers'>[] = []; - const orgMembersToUnhide: { - participantId: number; - orgMemberPublicId: TypeId<'orgMembers'>; - }[] = []; + const spacesForNotification = convoQuery.spaces.map( + (space) => space.space.publicId + ); + + // const orgMembersToUnhide: { + // participantId: number; + // spacePublicId: TypeId<'spaces'>; + // }[] = []; let convoEntryPublicId: TypeId<'convoEntries'> | null = null; - convoQuery.participants.forEach((participant) => { - if (participant.orgMember) { - orgMembersForNotificationPublicIds.push(participant.orgMember.publicId); + // convoQuery.participants.forEach((participant) => { + // if (participant.orgMember) { + // orgMembersForNotificationPublicIds.push({ + // orgMemberPublicId: participant.orgMember.publicId, + // spaceShortcode: '' + // }); - if (participant.hidden) { - orgMembersToUnhide.push({ - participantId: participant.id, - orgMemberPublicId: participant.orgMember.publicId - }); - } - } - }); + // if (participant.hidden) { + // orgMembersToUnhide.push({ + // participantId: participant.id, + // orgMemberPublicId: participant.orgMember.publicId + // }); + // } + // } + // }); if (!newConvo) { const convoEntryQuery = await db.query.convoEntries.findFirst({ @@ -85,50 +99,47 @@ export async function sendRealtimeNotification({ } if (newConvo || !convoEntryPublicId) { - await realtime - .emit({ - event: 'convo:new', - orgMemberPublicIds: orgMembersForNotificationPublicIds, - data: { - publicId: convoPublicId - } - }) - .catch(console.error); + await Promise.allSettled( + spacesForNotification.map( + async (spacePublicId) => + await realtime.emitOnChannels({ + channel: `private-space-${spacePublicId}`, + event: 'convo:new', + data: { + publicId: convoPublicId + } + }) + ) + ); } else { - await realtime - .emit({ - event: 'convo:entry:new', - orgMemberPublicIds: orgMembersForNotificationPublicIds, - data: { - convoPublicId, - convoEntryPublicId - } - }) - .catch(console.error); - if (orgMembersToUnhide.length > 0) { - const participantIds = orgMembersToUnhide.map( - (orgMember) => orgMember.participantId - ); - await db - .update(convoParticipants) - .set({ - hidden: false - }) - .where(inArray(convoParticipants.id, participantIds)); + await Promise.allSettled( + spacesForNotification.map( + async (spacePublicId) => + await realtime.emitOnChannels({ + channel: `private-space-${spacePublicId}`, + event: 'convo:entry:new', + data: { + convoPublicId, + convoEntryPublicId + } + }) + ) + ); - const orgMemberPublicIds = orgMembersToUnhide.map( - (orgMember) => orgMember.orgMemberPublicId - ); - await realtime - .emit({ - event: 'convo:hidden', - orgMemberPublicIds: orgMemberPublicIds, - data: { - publicId: convoPublicId, - hidden: false - } - }) - .catch(console.error); - } + // if (orgMembersToUnhide.length > 0) { + // const participantIds = orgMembersToUnhide.map( + // (orgMember) => orgMember.participantId + // ); + // await db + // .update(convoParticipants) + // .set({ + // hidden: false + // }) + // .where(inArray(convoParticipants.id, participantIds)); + + // const orgMemberPublicIds = orgMembersToUnhide.map( + // (orgMember) => orgMember.orgMemberPublicId + // ); + // } } } diff --git a/apps/mail-bridge/utils/spaceUtils.ts b/apps/mail-bridge/utils/spaceUtils.ts new file mode 100644 index 00000000..f26a561e --- /dev/null +++ b/apps/mail-bridge/utils/spaceUtils.ts @@ -0,0 +1,105 @@ +import { + convos, + convoToSpaces, + convoWorkflows, + spaces, + spaceWorkflows +} from '@u22n/database/schema'; +import { typeIdGenerator } from '@u22n/utils/typeid'; +import { eq, and } from '@u22n/database/orm'; +import { type DBType } from '@u22n/database'; + +//! copy of functions from apps/platform/trpc/routers/spaceRouter/utils.ts + +export async function addConvoToSpace({ + db, + orgId, + convoId, + spaceId +}: { + db: DBType; + orgId: number; + convoId: number; + spaceId: number; +}) { + // validate that the space and convo exist and belong to the same org + const spaceQuery = await db.query.spaces.findFirst({ + where: and(eq(spaces.orgId, orgId), eq(spaces.id, spaceId)), + columns: { + id: true, + createdByOrgMemberId: true + } + }); + if (!spaceQuery) { + throw new Error('❌addConvoToSpace: Space not found'); + } + const convoQuery = await db.query.convos.findFirst({ + where: and(eq(convos.orgId, orgId), eq(convos.id, convoId)), + columns: { + id: true + } + }); + if (!convoQuery) { + throw new Error('❌addConvoToSpace: Convo not found'); + } + + // check if the convo is already in the space + const convoToSpacesQuery = await db.query.convoToSpaces.findMany({ + where: and( + eq(convoToSpaces.orgId, orgId), + eq(convoToSpaces.convoId, convoId), + eq(convoToSpaces.spaceId, spaceId) + ), + columns: { + id: true + } + }); + if (convoToSpacesQuery.length > 0) { + return; + } + + // add the convo to the space + const newConvoToSpaceInsert = await db.insert(convoToSpaces).values({ + orgId: orgId, + convoId: convoId, + spaceId: spaceId, + publicId: typeIdGenerator('convoToSpaces') + }); + + // check if the space has "open" workflows + const spaceWorkflowsQuery = await db.query.spaceWorkflows.findMany({ + where: and( + eq(spaceWorkflows.orgId, orgId), + eq(spaceWorkflows.spaceId, spaceId), + eq(spaceWorkflows.type, 'open') + ), + columns: { + id: true, + disabled: true, + order: true + } + }); + + if (!spaceWorkflowsQuery || spaceWorkflowsQuery.length === 0) { + return; + } + + // check first convoWorkflow type === open + const openWorkflows = spaceWorkflowsQuery.sort((a, b) => a.order - b.order); + if (openWorkflows && openWorkflows.length > 0) { + const firstOpenWorkflow = openWorkflows?.[0]; + if (firstOpenWorkflow) { + await db.insert(convoWorkflows).values({ + orgId: orgId, + convoId: convoId, + spaceId: spaceId, + convoToSpaceId: Number(newConvoToSpaceInsert.insertId), + publicId: typeIdGenerator('convoWorkflows'), + workflow: firstOpenWorkflow.id, + byOrgMemberId: spaceQuery.createdByOrgMemberId + }); + return; + } + } + return; +} diff --git a/apps/platform/routes/realtime.ts b/apps/platform/routes/realtime.ts index 9f8c1dd2..8d262131 100644 --- a/apps/platform/routes/realtime.ts +++ b/apps/platform/routes/realtime.ts @@ -1,5 +1,7 @@ +import { verifySpaceMembership } from '~platform/trpc/routers/spaceRouter/utils'; import { validateOrgShortcode } from '~platform/utils/orgShortcode'; import { realtime } from '~platform/utils/realtime'; +import { validateTypeId } from '@u22n/utils/typeid'; import { orgMembers } from '@u22n/database/schema'; import { zValidator } from '@u22n/hono/helpers'; import { and, eq } from '@u22n/database/orm'; @@ -45,10 +47,61 @@ realtimeApi.post( } return c.json( - realtime.authenticate( + realtime.authenticateOrgMember( c.req.valid('json').socketId, orgMemberObject.publicId ) ); } ); + +realtimeApi.post( + '/authorize', + zValidator( + 'json', + z.object({ channelName: z.string(), socketId: z.string() }) + ), + zValidator('header', z.object({ 'org-shortcode': z.string() })), + async (c) => { + const { channelName, socketId } = c.req.valid('json'); + const [visibility, type, id] = channelName.split('-'); + + if ( + visibility !== 'private' || + type !== 'space' || + !validateTypeId('spaces', id) + ) + return c.json({ error: 'Forbidden' }, 403); + + const accountContext = c.get('account'); + const orgContext = await validateOrgShortcode( + c.req.valid('header')['org-shortcode'] + ); + + if (!orgContext || !accountContext) { + return c.json({ error: 'Forbidden' }, 403); + } + + const orgMemberId = orgContext.members.find( + (m) => m.accountId === accountContext.id + )?.id; + + if (!orgMemberId) { + return c.json({ error: 'Forbidden' }, 403); + } + + const hasAccess = await verifySpaceMembership({ + orgId: orgContext.id, + orgMemberId: orgMemberId, + spacePublicId: id + }).catch(() => { + return false; + }); + + if (!hasAccess) { + return c.json({ error: 'Forbidden' }, 403); + } + + return c.json(realtime.authorizeSpaceChannel(socketId, id)); + } +); diff --git a/apps/platform/trpc/index.ts b/apps/platform/trpc/index.ts index f54240d1..5e3d4fff 100644 --- a/apps/platform/trpc/index.ts +++ b/apps/platform/trpc/index.ts @@ -18,6 +18,7 @@ import { addressRouter } from './routers/userRouter/addressRouter'; import { storeRouter } from './routers/orgRouter/orgStoreRouter'; import { signupRouter } from './routers/authRouter/signupRouter'; import { convoRouter } from './routers/convoRouter/convoRouter'; +import { spaceRouter } from './routers/spaceRouter/spaceRouter'; import { crudRouter } from './routers/orgRouter/orgCrudRouter'; import { router } from './trpc'; @@ -64,7 +65,8 @@ export const trpcPlatformRouter = router({ auth: trpcPlatformAuthRouter, account: trpcPlatformAccountRouter, org: trpcPlatformOrgRouter, - convos: convoRouter + convos: convoRouter, + spaces: spaceRouter }); export type TrpcPlatformRouter = typeof trpcPlatformRouter; diff --git a/apps/platform/trpc/routers/convoRouter/convoRouter.ts b/apps/platform/trpc/routers/convoRouter/convoRouter.ts index 1f684747..583027ae 100644 --- a/apps/platform/trpc/routers/convoRouter/convoRouter.ts +++ b/apps/platform/trpc/routers/convoRouter/convoRouter.ts @@ -16,7 +16,11 @@ import { convoParticipantTeamMembers, emailIdentities, convoEntryPrivateVisibilityParticipants, - convoEntryRawHtmlEmails + convoEntryRawHtmlEmails, + spaces, + convoToSpaces, + spaceWorkflows, + convoWorkflows } from '@u22n/database/schema'; import { type InferInsertModel, @@ -24,8 +28,7 @@ import { eq, inArray, desc, - or, - lt + or } from '@u22n/database/orm'; import { tryParseInlineProxyUrl, @@ -36,20 +39,21 @@ import { type TypeId, typeIdGenerator } from '@u22n/utils/typeid'; +import { addConvoToSpace, isOrgMemberSpaceMember } from '../spaceRouter/utils'; import { realtime, sendRealtimeNotification } from '~platform/utils/realtime'; import { mailBridgeTrpcClient } from '~platform/utils/tRPCServerClients'; import { createExtensionSet } from '@u22n/tiptap/extensions'; +import type { SpaceWorkflowType } from '@u22n/utils/spaces'; import { router, orgProcedure } from '~platform/trpc/trpc'; import { type JSONContent } from '@u22n/tiptap/react'; +import type { UiColor } from '@u22n/utils/colors'; import { convoEntryRouter } from './entryRouter'; import { tiptapCore } from '@u22n/tiptap'; import { TRPCError } from '@trpc/server'; import { env } from '~platform/env'; import { z } from 'zod'; -const tipTapExtensions = createExtensionSet({ - storageUrl: env.STORAGE_URL -}); +const tipTapExtensions = createExtensionSet({ storageUrl: env.STORAGE_URL }); type Attachment = { orgPublicId: TypeId<'org'>; @@ -95,7 +99,8 @@ export const convoRouter = router({ type: z.string() }) ), - hide: z.boolean().default(false) + // hide: z.boolean().default(false), + spaceShortcode: z.string() }) ) .mutation(async ({ ctx, input }) => { @@ -111,10 +116,71 @@ export const convoRouter = router({ participantsContactsPublicIds, topic, to: convoMessageTo, - firstMessageType + firstMessageType, + spaceShortcode } = input; const message = input.message as JSONContent; + + // if there is a send as email identity, check if that email identity is enabled + if (sendAsEmailIdentityPublicId) { + const emailIdentityResponse = await db.query.emailIdentities.findFirst({ + where: and( + eq(emailIdentities.orgId, orgId), + eq(emailIdentities.publicId, sendAsEmailIdentityPublicId) + ), + columns: {}, + with: { + domain: { + columns: { + domainStatus: true, + sendingMode: true + } + } + } + }); + + if (!emailIdentityResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Send as email identity not found' + }); + } + + if (emailIdentityResponse.domain) { + if ( + emailIdentityResponse.domain.domainStatus !== 'active' || + emailIdentityResponse.domain.sendingMode === 'disabled' + ) { + throw new TRPCError({ + code: 'UNPROCESSABLE_CONTENT', + message: + 'You cant send from that email address due to a configuration issue. Please contact your administrator or select a different email identity.' + }); + } + } + } + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if ( + spaceMembershipResponse.type !== 'open' && + spaceMembershipResponse.role === null + ) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not allowed to send a message in this space' + }); + } + const spacesToAddConvoTo: number[] = []; + + spacesToAddConvoTo.push(spaceMembershipResponse.spaceId); + let convoParticipantToPublicId: TypeId<'convoParticipants'>; let convoMessageToNewContactPublicId: TypeId<'contacts'>; @@ -158,40 +224,42 @@ export const convoRouter = router({ ), columns: { id: true, - publicId: true - }, - with: { - authorizedEmailIdentities: { - columns: { - id: true, - default: true - }, - with: { - emailIdentity: { - columns: { - id: true - } - } - } - } + publicId: true, + defaultEmailIdentityId: true } }); - for (const orgMember of orgMemberResponses) { - let emailIdentityId = orgMember.authorizedEmailIdentities.find( - (emailIdentity) => emailIdentity.default - )?.emailIdentity.id; + for (const orgMemberParticipant of orgMemberResponses) { + orgMemberIds.push({ + id: orgMemberParticipant.id, + publicId: orgMemberParticipant.publicId, + emailIdentityId: orgMemberParticipant.defaultEmailIdentityId ?? null + }); + + const canUserAccessSpace = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: orgMemberParticipant.id + }); + + if ( + canUserAccessSpace.role === null && + canUserAccessSpace.type !== 'open' + ) { + const orgMemberQueryResponse = await db.query.orgMembers.findFirst({ + where: and( + eq(orgMembers.orgId, orgId), + eq(orgMembers.id, orgMemberParticipant.id) + ), + columns: { + personalSpaceId: true + } + }); - if (!emailIdentityId) { - emailIdentityId = - orgMember.authorizedEmailIdentities[0]?.emailIdentity.id; + orgMemberQueryResponse?.personalSpaceId && + spacesToAddConvoTo.push(orgMemberQueryResponse.personalSpaceId); } - const orgMemberIdObject: IdPairOrgMembers = { - id: orgMember.id, - publicId: orgMember.publicId, - emailIdentityId: emailIdentityId ?? null - }; - orgMemberIds.push(orgMemberIdObject); } if (orgMemberIds.length !== participantsOrgMembersPublicIds.length) { @@ -211,40 +279,62 @@ export const convoRouter = router({ ), columns: { id: true, - publicId: true - }, - with: { - authorizedEmailIdentities: { - columns: { - id: true, - default: true - }, - with: { - emailIdentity: { - columns: { - id: true - } + publicId: true, + defaultEmailIdentityId: true, + defaultSpaceId: true + } + }); + + for (const teamParticipant of teamResponses) { + orgTeamIds.push({ + id: teamParticipant.id, + publicId: teamParticipant.publicId, + emailIdentityId: teamParticipant.defaultEmailIdentityId ?? null + }); + + // Check if the team already has access to the space the convo was created in, if yes, don't add the convo to their default space, else, add convo to their default space + const spaceQueryResponse = await db.query.spaces.findFirst({ + where: and( + eq(spaces.orgId, orgId), + eq(spaces.shortcode, spaceShortcode) + ), + columns: { + id: true, + publicId: true, + type: true + }, + with: { + members: { + columns: { + teamId: true } } } - } - }); + }); + + if (!spaceQueryResponse) break; + + const spaceMembersWhoAreTeams = spaceQueryResponse?.members.filter( + (spaceMember) => spaceMember.teamId !== null + ); - for (const team of teamResponses) { - let emailIdentityId = team.authorizedEmailIdentities.find( - (emailIdentity) => emailIdentity.default - )?.emailIdentity.id; + if ( + spaceMembersWhoAreTeams.length === 0 && + spaceQueryResponse?.type !== 'open' + ) { + const teamQueryResponse = await db.query.teams.findFirst({ + where: and( + eq(teams.orgId, orgId), + eq(teams.id, teamParticipant.id) + ), + columns: { + defaultSpaceId: true + } + }); - if (!emailIdentityId) { - emailIdentityId = - team.authorizedEmailIdentities[0]?.emailIdentity.id; + teamQueryResponse?.defaultSpaceId && + spacesToAddConvoTo.push(teamQueryResponse.defaultSpaceId); } - const teamObject: IdPair = { - id: team.id, - publicId: team.publicId, - emailIdentityId: emailIdentityId ?? null - }; - orgTeamIds.push(teamObject); } if (orgTeamIds.length !== participantsTeamsPublicIds.length) { @@ -452,6 +542,17 @@ export const convoRouter = router({ lastUpdatedAt: newConvoTimestamp }); + // add the conversation to the space(s) + + for (const spaceToAdd of spacesToAddConvoTo) { + await addConvoToSpace({ + db, + orgId, + convoId: Number(insertConvoResponse.insertId), + spaceId: spaceToAdd + }); + } + // create conversationSubject entry const newConvoSubjectPublicId = typeIdGenerator('convoSubjects'); const insertConvoSubjectResponse = await db.insert(convoSubjects).values({ @@ -635,7 +736,8 @@ export const convoRouter = router({ publicId: authorConvoParticipantPublicId, orgMemberId: accountOrgMemberId, emailIdentityId: authorEmailIdentityId, - hidden: input.hide, + // hidden: input.hide, + hidden: false, role: 'assigned' }); @@ -779,10 +881,10 @@ export const convoRouter = router({ }); } - await realtime.emit({ - orgMemberPublicIds: orgMemberPublicIdsForNotifications, - event: 'convo:new', - data: { publicId: newConvoPublicId } + await sendRealtimeNotification({ + newConvo: true, + convoId: Number(insertConvoResponse.insertId), + convoEntryId: -1 }); return { @@ -808,8 +910,8 @@ export const convoRouter = router({ inline: z.boolean().default(false) }) ), - messageType: z.enum(['message', 'draft', 'comment']), - hide: z.boolean().default(false) + messageType: z.enum(['message', 'draft', 'comment']) + // hide: z.boolean().default(false) }) ) .mutation(async ({ ctx, input }) => { @@ -842,6 +944,18 @@ export const convoRouter = router({ publicId: true }, with: { + spaces: { + columns: { + id: true + }, + with: { + space: { + columns: { + id: true + } + } + } + }, participants: { columns: { id: true, @@ -872,9 +986,44 @@ export const convoRouter = router({ }); } + const allSpaceIdsWhereConvoAlreadyExists = + convoEntryToReplyToQueryResponse.convo.spaces.map( + (space) => space.space.id + ); + // get the email identity the user wants to email from let emailIdentityId: number | null = null; + if (sendAsEmailIdentityPublicId) { + const sendAsEmailIdentityResponse = + await db.query.emailIdentities.findFirst({ + where: and( + eq(emailIdentities.orgId, orgId), + eq(emailIdentities.publicId, sendAsEmailIdentityPublicId) + ), + columns: { + id: true + } + }); + + //! fix user authorization via spaceId + // const userIsAuthorized = + // sendAsEmailIdentityResponse?.authorizedSenders.some( + // (authorizedOrgMember) => + // authorizedOrgMember.orgMemberId === accountOrgMemberId || + // authorizedOrgMember.team?.members.some( + // (teamMember) => teamMember.orgMemberId === accountOrgMemberId + // ) + // ); + // if (!userIsAuthorized) { + // throw new TRPCError({ + // code: 'UNAUTHORIZED', + // message: 'User is not authorized to send as this email identity' + // }); + // } + emailIdentityId = sendAsEmailIdentityResponse?.id ?? null; + } + let authorConvoParticipantId: number | undefined; let authorConvoParticipantPublicId: | TypeId<'convoParticipants'> @@ -938,10 +1087,11 @@ export const convoRouter = router({ id: true }, with: { - authorizedOrgMembers: { + authorizedSenders: { columns: { orgMemberId: true, - teamId: true + teamId: true, + spaceId: true }, with: { team: { @@ -961,15 +1111,36 @@ export const convoRouter = router({ } }); - const userIsAuthorized = - sendAsEmailIdentityResponse?.authorizedOrgMembers.some( - (authorizedOrgMember) => - authorizedOrgMember.orgMemberId === accountOrgMemberId || - authorizedOrgMember.team?.members.some( - (teamMember) => teamMember.orgMemberId === accountOrgMemberId - ) + const authedOrgMemberIds = + sendAsEmailIdentityResponse?.authorizedSenders?.map( + (authorizedOrgMember) => authorizedOrgMember.orgMemberId + ) ?? []; + const authedTeamMemberIds: number[] = []; + sendAsEmailIdentityResponse?.authorizedSenders?.map( + (authorizedSender) => + authorizedSender.team?.members.map((teamMember) => + authedTeamMemberIds.push(teamMember.orgMemberId) + ) + ); + const authedSpacedIds = + sendAsEmailIdentityResponse?.authorizedSenders?.map( + (authorizedOrgMember) => authorizedOrgMember.spaceId + ) ?? []; + + const orgMemberIsAuthorizedToUseEmailIdentity = + authedOrgMemberIds.some( + (authedOrgMemberId) => authedOrgMemberId === accountOrgMemberId + ) || + authedTeamMemberIds?.some( + (authedTeamMemberId) => authedTeamMemberId === accountOrgMemberId + ) || + authedSpacedIds.some( + (authedSpacedId) => + authedSpacedId && + allSpaceIdsWhereConvoAlreadyExists.includes(authedSpacedId) ); - if (!userIsAuthorized) { + + if (!orgMemberIsAuthorizedToUseEmailIdentity) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User is not authorized to send as this email identity' @@ -1135,14 +1306,14 @@ export const convoRouter = router({ }) .onDuplicateKeyUpdate({ set: { seenAt: new Date() } }); - if (input.hide) { - await db - .update(convoParticipants) - .set({ - hidden: true - }) - .where(eq(convoParticipants.id, authorConvoParticipantId)); - } + // if (input.hide) { + // await db + // .update(convoParticipants) + // .set({ + // hidden: true + // }) + // .where(eq(convoParticipants.id, authorConvoParticipantId)); + // } //* send notifications await sendRealtimeNotification({ @@ -1166,55 +1337,12 @@ export const convoRouter = router({ }) ) .query(async ({ ctx, input }) => { - const { db, account, org } = ctx; - const accountId = account.id; + const { db, org } = ctx; const orgId = org.id; const accountOrgMemberId = org.memberId; const { convoPublicId } = input; - // check if the conversation belongs to the same org, early return if not before multiple db selects - const convoResponse = await db.query.convos.findFirst({ - where: eq(convos.publicId, convoPublicId), - columns: { - id: true, - orgId: true - } - }); - if (!convoResponse) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Conversation not found' - }); - } - if (Number(convoResponse.orgId) !== orgId) { - const convoOrgOwnerMembersIds = await db.query.orgMembers.findMany({ - where: eq(orgMembers.orgId, convoResponse.orgId), - columns: { - accountId: true - }, - with: { - org: { - columns: { - name: true - } - } - } - }); - const convoOrgOwnerUserIds = convoOrgOwnerMembersIds.map((member) => - Number(member?.accountId ?? 0) - ); - if (!convoOrgOwnerUserIds.includes(accountId)) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Conversation not found' - }); - } - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: `Conversation is not owned by your organization.` - }); - } - + // initial low column select to verify convo exists // TODO: Add filtering for org based on input.filterOrgPublicId const convoDetails = await db.query.convos.findFirst({ columns: { @@ -1222,7 +1350,7 @@ export const convoRouter = router({ lastUpdatedAt: true, createdAt: true }, - where: eq(convos.id, convoResponse.id), + where: and(eq(convos.orgId, orgId), eq(convos.publicId, convoPublicId)), with: { subjects: { columns: { @@ -1306,6 +1434,18 @@ export const convoRouter = router({ inline: true, createdAt: true } + }, + spaces: { + columns: { + publicId: true + }, + with: { + space: { + columns: { + shortcode: true + } + } + } } } }); @@ -1316,35 +1456,55 @@ export const convoRouter = router({ }); } + const allSpacesShortcodes = convoDetails.spaces.map( + (space) => space.space.shortcode + ); + const orgMemberIsSpaceMember = allSpacesShortcodes.some( + async (spaceShortcode) => { + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: spaceShortcode, + orgMemberId: org.memberId + }); + if ( + spaceMembershipResponse.type !== 'open' && + spaceMembershipResponse.role === null + ) { + return false; + } + return true; + } + ); + + if (!orgMemberIsSpaceMember) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You do not have permission to access this conversation' + }); + } + // Find the participant.publicId for the accountOrgMemberId - let participantPublicId: string | undefined; + let ownParticipantPublicId: string | undefined; // Check if the user's orgMemberId is in the conversation participants convoDetails?.participants.forEach((participant) => { if (participant.orgMember?.id === accountOrgMemberId) { - participantPublicId = participant.publicId; + ownParticipantPublicId = participant.publicId; } }); // If not found, check if the user's orgMemberId is in any participant's team members - if (!participantPublicId) { + if (!ownParticipantPublicId) { convoDetails?.participants.forEach((participant) => { participant.team?.members.forEach((teamMember) => { if (teamMember.orgMemberId === accountOrgMemberId) { - participantPublicId = participant.publicId; + ownParticipantPublicId = participant.publicId; } }); }); } - // If participantPublicId is still not found, the user is not a participant of this conversation - if (!participantPublicId) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'You are not a participant of this conversation' - }); - } - // strip the user IDs from the response convoDetails.participants.forEach((participant) => { if (participant.orgMember?.id) participant.orgMember.id = 0; @@ -1354,87 +1514,46 @@ export const convoRouter = router({ }); // updates the lastReadAt of the participant - await db - .update(convoParticipants) - .set({ - lastReadAt: new Date() - }) - .where( - eq(convoParticipants.publicId, participantPublicId as `cp_${string}`) - ); + if (ownParticipantPublicId) { + await db + .update(convoParticipants) + .set({ + lastReadAt: new Date() + }) + .where( + eq( + convoParticipants.publicId, + ownParticipantPublicId as `cp_${string}` + ) + ); + } return { data: convoDetails, - ownParticipantPublicId: participantPublicId + ownParticipantPublicId: ownParticipantPublicId ?? null }; }), - getOrgMemberConvos: orgProcedure + // used for data store + getOrgMemberSpecificConvo: orgProcedure .input( z.object({ - includeHidden: z.boolean().default(false), - cursor: z - .object({ - lastUpdatedAt: z.date().optional(), - lastPublicId: typeIdValidator('convos').optional() - }) - .default({}) + convoPublicId: typeIdValidator('convos') }) ) .query(async ({ ctx, input }) => { const { db, org } = ctx; - const { cursor } = input; - const orgId = org.id; - - const orgMemberId = org.memberId; - const LIMIT = 15; - - const inputLastUpdatedAt = cursor.lastUpdatedAt - ? new Date(cursor.lastUpdatedAt) - : new Date(); - - const inputLastPublicId = cursor.lastPublicId ?? 'c_'; + const { convoPublicId } = input; + const accountOrgMemberId = org.memberId; - const convoQuery = await db.query.convos.findMany({ - orderBy: [desc(convos.lastUpdatedAt), desc(convos.publicId)], - limit: LIMIT + 1, + const convoQuery = await db.query.convos.findFirst({ columns: { publicId: true, lastUpdatedAt: true }, where: and( - or( - and( - eq(convos.orgId, orgId), - eq(convos.lastUpdatedAt, inputLastUpdatedAt), - lt(convos.publicId, inputLastPublicId) - ), - and( - eq(convos.orgId, orgId), - lt(convos.lastUpdatedAt, inputLastUpdatedAt) - ) - ), - inArray( - convos.id, - db - .select({ id: convoParticipants.convoId }) - .from(convoParticipants) - .where( - and( - eq(convoParticipants.hidden, input.includeHidden), - or( - eq(convoParticipants.orgMemberId, orgMemberId), - inArray( - convoParticipants.teamId, - db - .select({ id: teamMembers.teamId }) - .from(teamMembers) - .where(eq(teamMembers.orgMemberId, orgMemberId)) - ) - ) - ) - ) - ) + eq(convos.publicId, convoPublicId), + eq(convos.orgId, org.id) ), with: { subjects: { @@ -1451,7 +1570,10 @@ export const convoRouter = router({ }, with: { orgMember: { - columns: { publicId: true }, + columns: { + publicId: true, + id: true + }, with: { profile: { columns: { @@ -1542,267 +1664,140 @@ export const convoRouter = router({ } }); - // As we fetch ${LIMIT + 1} convos at a time, if the length is <= ${LIMIT}, we know we've reached the end - if (convoQuery.length <= LIMIT) { - return { - data: convoQuery, - cursor: null - }; + if (!convoQuery?.publicId) { + return null; } - // If we have ${LIMIT + 1} convos, we pop the last one as we return ${LIMIT} convos - convoQuery.pop(); + const participant = convoQuery?.participants.find((participant) => { + return participant.orgMember?.id === accountOrgMemberId; + }); - const newCursorLastUpdatedAt = - convoQuery[convoQuery.length - 1]!.lastUpdatedAt; - const newCursorLastPublicId = convoQuery[convoQuery.length - 1]!.publicId; - - return { - data: convoQuery, - cursor: { - lastUpdatedAt: newCursorLastUpdatedAt, - lastPublicId: newCursorLastPublicId - } - }; - }), - // used for data store - getOrgMemberSpecificConvo: orgProcedure - .input( - z.object({ - convoPublicId: typeIdValidator('convos') - }) - ) - .query(async ({ ctx, input }) => { - const { db, org } = ctx; - const { convoPublicId } = input; - const accountOrgMemberId = org.memberId; - - const convoQuery = await db.query.convos.findFirst({ - columns: { - publicId: true, - lastUpdatedAt: true - }, - where: and( - eq(convos.publicId, convoPublicId), - eq(convos.orgId, org.id) - ), - with: { - subjects: { - columns: { - subject: true - } - }, - participants: { - columns: { - role: true, - publicId: true, - hidden: true, - notifications: true - }, - with: { - orgMember: { - columns: { - publicId: true, - id: true - }, - with: { - profile: { - columns: { - publicId: true, - firstName: true, - lastName: true, - avatarTimestamp: true, - handle: true - } - } - } - }, - team: { - columns: { - publicId: true, - name: true, - color: true, - avatarTimestamp: true - } - }, - contact: { - columns: { - publicId: true, - name: true, - avatarTimestamp: true, - setName: true, - emailUsername: true, - emailDomain: true, - type: true, - signaturePlainText: true, - signatureHtml: true - } - } - } - }, - entries: { - orderBy: [desc(convoEntries.createdAt)], - limit: 1, - columns: { - bodyPlainText: true, - type: true - }, - with: { - author: { - columns: {}, - with: { - orgMember: { - columns: { - publicId: true - }, - with: { - profile: { - columns: { - publicId: true, - firstName: true, - lastName: true, - avatarTimestamp: true, - handle: true - } - } - } - }, - team: { - columns: { - publicId: true, - name: true, - color: true, - avatarTimestamp: true - } - }, - contact: { - columns: { - publicId: true, - name: true, - avatarTimestamp: true, - setName: true, - emailUsername: true, - emailDomain: true, - type: true - } - } - } - } - } - } - } - }); - - if (!convoQuery?.publicId) { - return null; - } - - const participant = convoQuery?.participants.find((participant) => { - return participant.orgMember?.id === accountOrgMemberId; - }); - - // If participant is still not found, the user is not a participant of this conversation - if (!participant) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'You are not a participant of this conversation' - }); - } + // If participant is still not found, the user is not a participant of this conversation + // if (!participant) { + // throw new TRPCError({ + // code: 'UNAUTHORIZED', + // message: 'You are not a participant of this conversation' + // }); + // } // updates the lastReadAt of the participant - await db - .update(convoParticipants) - .set({ - lastReadAt: new Date() - }) - .where(eq(convoParticipants.publicId, participant.publicId)); - - return convoQuery; - }), - hideConvo: orgProcedure - .input( - z.object({ - convoPublicId: z - .array(typeIdValidator('convos')) - .or(typeIdValidator('convos')), - unhide: z.boolean().default(false) - }) - ) - .mutation(async ({ ctx, input }) => { - const { db, org } = ctx; - const { convoPublicId } = input; - const orgMemberId = org.memberId; - const convoPublicIds = Array.isArray(convoPublicId) - ? convoPublicId - : [convoPublicId]; - - const convosQuery = await db.query.convos.findMany({ - columns: { - id: true - }, - where: and( - inArray(convos.publicId, convoPublicIds), - eq(convos.orgId, org.id) - ), - with: { - participants: { - columns: { - id: true - }, - where: eq(convoParticipants.orgMemberId, orgMemberId), - with: { - orgMember: { - columns: { - publicId: true - } - } - } - } - } - }); - - if (convosQuery.length !== convoPublicIds.length) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'One or more conversations not found' - }); - } - - const orgMemberConvoParticipants = convosQuery - .map((convo) => convo.participants[0]) - .filter((participant) => typeof participant !== 'undefined'); - - if (orgMemberConvoParticipants.length !== convoPublicIds.length) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'One or more conversations not found' - }); + if (participant) { + await db + .update(convoParticipants) + .set({ + lastReadAt: new Date() + }) + .where(eq(convoParticipants.publicId, participant.publicId)); } - await db - .update(convoParticipants) - .set({ - hidden: !input.unhide - }) - .where( - inArray( - convoParticipants.id, - orgMemberConvoParticipants.map((p) => p.id) - ) - ); - - const orgMemberPublicIdsForNotifications = Array.from( - new Set(orgMemberConvoParticipants.map((p) => p.orgMember!.publicId)) - ); - - await realtime.emit({ - orgMemberPublicIds: orgMemberPublicIdsForNotifications, - event: 'convo:hidden', - data: { publicId: convoPublicIds, hidden: !input.unhide } - }); - - return { success: true }; + return convoQuery; }), + // hideConvo: orgProcedure + // .input( + // z.object({ + // convoPublicId: z + // .array(typeIdValidator('convos')) + // .or(typeIdValidator('convos')), + // unhide: z.boolean().default(false) + // }) + // ) + // .mutation(async ({ ctx, input }) => { + // const { db, org } = ctx; + // const { convoPublicId } = input; + // const orgMemberId = org.memberId; + // const convoPublicIds = Array.isArray(convoPublicId) + // ? convoPublicId + // : [convoPublicId]; + + // const convosQuery = await db.query.convos.findMany({ + // columns: { + // id: true + // }, + // where: and( + // inArray(convos.publicId, convoPublicIds), + // eq(convos.orgId, org.id) + // ), + // with: { + // participants: { + // columns: { + // id: true + // }, + // where: eq(convoParticipants.orgMemberId, orgMemberId), + // with: { + // orgMember: { + // columns: { + // publicId: true + // } + // } + // } + // } + // } + // }); + + // if (convosQuery.length !== convoPublicIds.length) { + // throw new TRPCError({ + // code: 'NOT_FOUND', + // message: 'One or more conversations not found' + // }); + // } + + // const orgMemberConvoParticipants = convosQuery + // .map((convo) => convo.participants[0]) + // .filter((participant) => typeof participant !== 'undefined'); + + // if (orgMemberConvoParticipants.length !== convoPublicIds.length) { + // throw new TRPCError({ + // code: 'NOT_FOUND', + // message: 'One or more conversations not found' + // }); + // } + + // await db + // .update(convoParticipants) + // .set({ + // hidden: !input.unhide + // }) + // .where( + // inArray( + // convoParticipants.id, + // orgMemberConvoParticipants.map((p) => p.id) + // ) + // ); + + // const orgMemberPublicIdsForNotifications = Array.from( + // new Set(orgMemberConvoParticipants.map((p) => p.orgMember!.publicId)) + // ); + + // const spaceShortCodes = await db.query.convoToSpaces + // .findMany({ + // where: inArray( + // convoToSpaces.convoId, + // convosQuery.map((convo) => convo.id) + // ), + // columns: { + // id: true + // }, + // with: { + // space: { + // columns: { + // shortcode: true + // } + // } + // } + // }) + // .then((spaces) => spaces.map((space) => space.space.shortcode)); + + // await realtime.emit({ + // orgMemberPublicIds: orgMemberPublicIdsForNotifications, + // event: 'convo:hidden', + // data: { + // publicId: convoPublicIds, + // hidden: !input.unhide + // } + // }); + + // return { success: true }; + // }), deleteConvo: orgProcedure .input( z.object({ @@ -1828,7 +1823,8 @@ export const convoRouter = router({ ), columns: { id: true, - orgId: true + orgId: true, + publicId: true }, with: { participants: { @@ -1859,6 +1855,25 @@ export const convoRouter = router({ columns: { id: true } + }, + spaces: { + columns: {}, + with: { + space: { + columns: { + publicId: true, + shortcode: true, + type: true + }, + with: { + members: { + columns: { + orgMemberId: true + } + } + } + } + } } } }); @@ -1873,14 +1888,23 @@ export const convoRouter = router({ } // Check if the user is a direct participant or a team member of the convo and create a boolean array - const userInConvos = convoQueryResponses.map((convo) => - convo.participants.some( - (participant) => - participant.orgMemberId === accountOrgMemberId || - participant.team?.members.some( - (teamMember) => teamMember.orgMemberId === accountOrgMemberId - ) - ) + //! TODO: Add support for permission based on space + const userInConvos = convoQueryResponses.map( + (convo) => + convo.participants.some( + (participant) => + participant.orgMemberId === accountOrgMemberId || + participant.team?.members.some( + (teamMember) => teamMember.orgMemberId === accountOrgMemberId + ) + ) || + convo.spaces.every( + (space) => + space.space.type === 'open' || + space.space.members.some( + (member) => member.orgMemberId === accountOrgMemberId + ) + ) ); // If not all convos are owned by the user, throw an error @@ -2028,7 +2052,6 @@ export const convoRouter = router({ } catch (error) { console.error('🔥 Failed to delete convo', error); // Rollback throws error for some reason, we need to return the trpc error not the rollback error - try { db.rollback(); } catch {} @@ -2040,28 +2063,561 @@ export const convoRouter = router({ } }); - const orgMemberPublicIdsForNotifications = Array.from( - new Set( - convoQueryResponses - .flatMap((convo) => - convo.participants.map( - (participant) => participant.orgMember?.publicId - ) - ) - .filter(Boolean) as TypeId<'orgMembers'>[] - ) - ); + const spaces: Record, TypeId<'convos'>[]> = {}; - if (orgMemberPublicIdsForNotifications.length > 0) { - await realtime.emit({ - orgMemberPublicIds: orgMemberPublicIdsForNotifications, - event: 'convo:deleted', - data: { publicId: convoPublicId } + convoQueryResponses.forEach((convo) => { + convo.spaces.forEach((space) => { + if (!spaces[space.space.publicId]) { + spaces[space.space.publicId] = []; + } + spaces[space.space.publicId]?.push(convo.publicId); }); - } + }); + + await Promise.allSettled( + Object.entries(spaces).map(async ([spacePublicId, convos]) => { + await realtime.emitOnChannels({ + channel: `private-space-${spacePublicId}`, + event: 'convo:deleted', + data: { + publicId: convos + } + }); + }) + ); return { success: true }; + }), + getConvoSpaceWorkflows: orgProcedure + .input( + z.object({ + convoPublicId: typeIdValidator('convos') + }) + ) + .query(async ({ ctx, input }) => { + const { db, org } = ctx; + const { convoPublicId } = input; + + const convosQuery = await db.query.convos.findFirst({ + where: eq(convos.publicId, convoPublicId), + columns: { + id: true + } + }); + if (!convosQuery) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Conversation not found' + }); + } + + const convoSpacesQuery = await db.query.convoToSpaces.findMany({ + where: and( + eq(convoToSpaces.convoId, convosQuery.id), + eq(convoToSpaces.orgId, org.id) + ), + columns: { + spaceId: true + }, + with: { + space: { + columns: { + publicId: true, + name: true, + color: true, + icon: true, + avatarTimestamp: true + }, + with: { + workflows: { + columns: { + publicId: true, + name: true, + color: true, + icon: true, + description: true, + type: true, + order: true, + disabled: true + } + } + } + }, + workflows: { + columns: { + spaceId: true, + createdAt: true + }, + orderBy: [desc(spaceWorkflows.createdAt)], + with: { + workflow: { + columns: { + publicId: true + } + }, + space: { + columns: { + publicId: true + } + } + } + } + } + }); + + if (!convoSpacesQuery) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: + 'Error: This Conversation is not in any Spaces, please contact support' + }); + } + + type ReturnSpaceData = { + space: { + publicId: TypeId<'spaces'>; + name: string; + color: UiColor; + icon: string; + avatarTimestamp: Date | null; + }; + currentWorkflow: { + publicId: TypeId<'spaceWorkflows'> | null; + }; + spaceWorkflows: { + open: { + publicId: TypeId<'spaceWorkflows'>; + name: string; + color: UiColor; + icon: string; + description: string | null; + type: SpaceWorkflowType; + order: number; + disabled: boolean; + }[]; + active: { + publicId: TypeId<'spaceWorkflows'>; + name: string; + color: UiColor; + icon: string; + description: string | null; + type: SpaceWorkflowType; + order: number; + disabled: boolean; + }[]; + closed: { + publicId: TypeId<'spaceWorkflows'>; + name: string; + color: UiColor; + icon: string; + description: string | null; + type: SpaceWorkflowType; + order: number; + disabled: boolean; + }[]; + }; + }; + + const returnData: ReturnSpaceData[] = convoSpacesQuery.map( + (convoSpace) => { + // Extract space data + const spaceData = { + publicId: convoSpace.space.publicId, + name: convoSpace.space.name, + color: convoSpace.space.color, + icon: convoSpace.space.icon, + avatarTimestamp: convoSpace.space.avatarTimestamp + }; + + // Extract current workflow data + const currentWorkflowData = { + publicId: + convoSpace.workflows?.find( + (workflow) => workflow.spaceId === convoSpace.spaceId + )?.workflow?.publicId ?? null + }; + + // Extract space workflows data + const spaceWorkflowsData = { + open: convoSpace.space.workflows + .filter((workflow) => workflow.type === 'open') + .sort((a, b) => a.order - b.order) + .map((workflow) => ({ + publicId: workflow.publicId, + name: workflow.name, + color: workflow.color, + icon: workflow.icon, + description: workflow.description, + type: workflow.type, + order: workflow.order, + disabled: workflow.disabled + })), + active: convoSpace.space.workflows + .filter((workflow) => workflow.type === 'active') + .sort((a, b) => a.order - b.order) + .map((workflow) => ({ + publicId: workflow.publicId, + name: workflow.name, + color: workflow.color, + icon: workflow.icon, + description: workflow.description, + type: workflow.type, + order: workflow.order, + disabled: workflow.disabled + })), + closed: convoSpace.space.workflows + .filter((workflow) => workflow.type === 'closed') + .sort((a, b) => a.order - b.order) + .map((workflow) => ({ + publicId: workflow.publicId, + name: workflow.name, + color: workflow.color, + icon: workflow.icon, + description: workflow.description, + type: workflow.type, + order: workflow.order, + disabled: workflow.disabled + })) + }; + + // Combine the data into the ReturnSpaceData format + const returnSpaceData: ReturnSpaceData = { + space: spaceData, + currentWorkflow: currentWorkflowData, + spaceWorkflows: spaceWorkflowsData + }; + + return returnSpaceData; + } + ); + + return returnData; + }), + setConvoSpaceWorkflow: orgProcedure + .input( + z.object({ + convoPublicId: typeIdValidator('convos'), + spacePublicId: typeIdValidator('spaces'), + workflowPublicId: typeIdValidator('spaceWorkflows') + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const { convoPublicId, spacePublicId, workflowPublicId } = input; + + const spaceQueryResponse = await db.query.spaces.findFirst({ + where: and( + eq(spaces.orgId, org.id), + eq(spaces.publicId, spacePublicId) + ), + columns: { + shortcode: true + } + }); + if (!spaceQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Space not found' + }); + } + + const orgMemberSpacePermissions = await isOrgMemberSpaceMember({ + db, + orgId: org.id, + spaceShortcode: spaceQueryResponse?.shortcode, + orgMemberId: org.memberId + }); + + if (!orgMemberSpacePermissions.permissions.canChangeWorkflow) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You are not allowed to change the workflow of this space' + }); + } + + const convoQueryResponse = await db.query.convos.findFirst({ + where: and( + eq(convos.orgId, org.id), + eq(convos.publicId, convoPublicId) + ), + columns: { + id: true + } + }); + if (!convoQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Conversation not found' + }); + } + + const convoToSpacesQueryResponse = await db.query.convoToSpaces.findFirst( + { + where: and( + eq(convoToSpaces.orgId, org.id), + eq(convoToSpaces.convoId, convoQueryResponse.id), + eq(convoToSpaces.spaceId, orgMemberSpacePermissions.spaceId) + ), + columns: { + id: true + } + } + ); + if (!convoToSpacesQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Conversation is not in this space' + }); + } + + const workflowQueryResponse = await db.query.spaceWorkflows.findFirst({ + where: and( + eq(spaceWorkflows.orgId, org.id), + eq(spaceWorkflows.publicId, workflowPublicId) + ), + columns: { + id: true + } + }); + if (!workflowQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Workflow not found' + }); + } + + await db.insert(convoWorkflows).values({ + publicId: typeIdGenerator('convoWorkflows'), + orgId: org.id, + convoId: convoQueryResponse.id, + convoToSpaceId: convoToSpacesQueryResponse.id, + spaceId: orgMemberSpacePermissions.spaceId, + workflow: workflowQueryResponse.id, + byOrgMemberId: org.memberId + }); + + return {}; + }), + addConvoToSpace: orgProcedure + .input( + z.object({ + convoPublicId: typeIdValidator('convos'), + spacePublicId: typeIdValidator('spaces') + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const { convoPublicId, spacePublicId } = input; + + const spaceQueryResponse = await db.query.spaces.findFirst({ + where: and( + eq(spaces.orgId, org.id), + eq(spaces.publicId, spacePublicId) + ), + columns: { + id: true + } + }); + if (!spaceQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Space not found' + }); + } + + const convoQueryResponse = await db.query.convos.findFirst({ + where: and( + eq(convos.orgId, org.id), + eq(convos.publicId, convoPublicId) + ), + columns: { + id: true + } + }); + if (!convoQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Conversation not found' + }); + } + + // get array of all spaces shortcodes the convo is currently in to see if user has permission to add to another space + const convoToSpacesQueryResponse = await db.query.convoToSpaces.findMany({ + where: and( + eq(convoToSpaces.orgId, org.id), + eq(convoToSpaces.convoId, convoQueryResponse.id) + ), + columns: { + spaceId: true + }, + with: { + space: { + columns: { + shortcode: true + } + } + } + }); + + if ( + !convoToSpacesQueryResponse || + convoToSpacesQueryResponse.length === 0 + ) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: + 'Conversation exists but is not in any Spaces, please contact support' + }); + } + + const existingSpaceShortcodes = convoToSpacesQueryResponse.map( + (space) => space.space.shortcode + ); + + const orgMemberCanAddConvoToOtherSpace = existingSpaceShortcodes.some( + async (spaceShortcode) => { + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId: org.id, + spaceShortcode: spaceShortcode, + orgMemberId: org.memberId + }); + + return spaceMembershipResponse.permissions.canAddToAnotherSpace; + } + ); + + if (!orgMemberCanAddConvoToOtherSpace) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: + 'You are not allowed to add this Conversation to another Space' + }); + } + + await addConvoToSpace({ + db, + orgId: org.id, + convoId: convoQueryResponse.id, + spaceId: spaceQueryResponse.id + }); + + return; + }), + moveConvoToSpace: orgProcedure + .input( + z.object({ + convoPublicId: typeIdValidator('convos'), + spacePublicId: typeIdValidator('spaces') + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const { convoPublicId, spacePublicId } = input; + + const spaceQueryResponse = await db.query.spaces.findFirst({ + where: and( + eq(spaces.orgId, org.id), + eq(spaces.publicId, spacePublicId) + ), + columns: { + id: true + } + }); + if (!spaceQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Space not found' + }); + } + + const convoQueryResponse = await db.query.convos.findFirst({ + where: and( + eq(convos.orgId, org.id), + eq(convos.publicId, convoPublicId) + ), + columns: { + id: true + } + }); + if (!convoQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Conversation not found' + }); + } + + // get array of all spaces shortcodes the convo is currently in to see if user has permission to add to another space + const convoToSpacesQueryResponse = await db.query.convoToSpaces.findMany({ + where: and( + eq(convoToSpaces.orgId, org.id), + eq(convoToSpaces.convoId, convoQueryResponse.id) + ), + columns: { + spaceId: true + }, + with: { + space: { + columns: { + shortcode: true + } + } + } + }); + + if ( + !convoToSpacesQueryResponse || + convoToSpacesQueryResponse.length === 0 + ) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: + 'Conversation exists but is not in any Spaces, please contact support' + }); + } + + const existingSpaceShortcodes = convoToSpacesQueryResponse.map( + (space) => space.space.shortcode + ); + + const orgMemberCanMoveConvoToOtherSpace = existingSpaceShortcodes.some( + async (spaceShortcode) => { + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId: org.id, + spaceShortcode: spaceShortcode, + orgMemberId: org.memberId + }); + + return spaceMembershipResponse.permissions.canMoveToAnotherSpace; + } + ); + if (!orgMemberCanMoveConvoToOtherSpace) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: + 'You are not allowed to move this Conversation to another Space' + }); + } + + await db + .delete(convoToSpaces) + .where( + and( + eq(convoToSpaces.orgId, org.id), + eq(convoToSpaces.convoId, convoQueryResponse.id) + ) + ); + + await addConvoToSpace({ + db, + orgId: org.id, + convoId: convoQueryResponse.id, + spaceId: spaceQueryResponse.id + }); + + return; }) }); diff --git a/apps/platform/trpc/routers/convoRouter/entryRouter.ts b/apps/platform/trpc/routers/convoRouter/entryRouter.ts index 24f5b13c..b7788ef1 100644 --- a/apps/platform/trpc/routers/convoRouter/entryRouter.ts +++ b/apps/platform/trpc/routers/convoRouter/entryRouter.ts @@ -22,7 +22,6 @@ export const convoEntryRouter = router({ const { db, org } = ctx; const orgId = org.id; - const accountOrgMemberId = org.memberId; const { convoPublicId, @@ -98,12 +97,12 @@ export const convoEntryRouter = router({ }); }); - if (!convoParticipantsOrgMemberIds.includes(accountOrgMemberId)) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'You are not a participant of this conversation' - }); - } + // if (!convoParticipantsOrgMemberIds.includes(accountOrgMemberId)) { + // throw new TRPCError({ + // code: 'UNAUTHORIZED', + // message: 'You are not a participant of this conversation' + // }); + // } const LIMIT = 15; // get the entries @@ -206,7 +205,6 @@ export const convoEntryRouter = router({ .query(async ({ ctx, input }) => { const { db, org } = ctx; const orgId = org.id; - const accountOrgMemberId = org.memberId; const { convoPublicId, convoEntryPublicId } = input; @@ -274,12 +272,12 @@ export const convoEntryRouter = router({ }); }); - if (!convoParticipantsOrgMemberIds.includes(accountOrgMemberId)) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'You are not a participant of this conversation' - }); - } + // if (!convoParticipantsOrgMemberIds.includes(accountOrgMemberId)) { + // throw new TRPCError({ + // code: 'UNAUTHORIZED', + // message: 'You are not a participant of this conversation' + // }); + // } // get the entries const convoEntryQuery = await db.query.convoEntries.findFirst({ @@ -363,7 +361,6 @@ export const convoEntryRouter = router({ .query(async ({ ctx, input }) => { const { db, org } = ctx; const orgId = org.id; - const accountOrgMemberId = org.memberId; const { convoEntryPublicId } = input; @@ -464,12 +461,12 @@ export const convoEntryRouter = router({ }); }); - if (!convoParticipantsOrgMemberIds.includes(accountOrgMemberId)) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'You are not a participant of this conversation' - }); - } + // if (!convoParticipantsOrgMemberIds.includes(accountOrgMemberId)) { + // throw new TRPCError({ + // code: 'UNAUTHORIZED', + // message: 'You are not a participant of this conversation' + // }); + // } return { rawEmailData: convoEntryQuery.rawHtml diff --git a/apps/platform/trpc/routers/orgRouter/iCanHaz/iCanHazRouter.ts b/apps/platform/trpc/routers/orgRouter/iCanHaz/iCanHazRouter.ts index 6965220b..c6320c3e 100644 --- a/apps/platform/trpc/routers/orgRouter/iCanHaz/iCanHazRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/iCanHaz/iCanHazRouter.ts @@ -15,6 +15,39 @@ export const iCanHazRouter = router({ return true; } return await billingTrpcClient.iCanHaz.team.query({ orgId: org.id }); + }), + space: orgProcedure.query(async ({ ctx }) => { + const { org, selfHosted } = ctx; + if (selfHosted) { + return { + open: true, + private: true + }; + } + return await billingTrpcClient.iCanHaz.space.query({ orgId: org.id }); + }), + spaceWorkflow: orgProcedure.query(async ({ ctx }) => { + const { org, selfHosted } = ctx; + if (selfHosted) { + return { + open: 8, + active: 8, + closed: 8 + }; + } + return await billingTrpcClient.iCanHaz.spaceWorkflow.query({ + orgId: org.id + }); + }), + spaceTag: orgProcedure.query(async ({ ctx }) => { + const { org, selfHosted } = ctx; + if (selfHosted) { + return { + open: true, + private: true + }; + } + return await billingTrpcClient.iCanHaz.spaceTag.query({ orgId: org.id }); }) }); diff --git a/apps/platform/trpc/routers/orgRouter/mail/emailIdentityExternalRouter.ts b/apps/platform/trpc/routers/orgRouter/mail/emailIdentityExternalRouter.ts index 2b578024..1b93e447 100644 --- a/apps/platform/trpc/routers/orgRouter/mail/emailIdentityExternalRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/mail/emailIdentityExternalRouter.ts @@ -4,8 +4,9 @@ import { emailRoutingRules, emailRoutingRulesDestinations, emailIdentities, - emailIdentitiesAuthorizedOrgMembers, - emailIdentityExternal + emailIdentitiesAuthorizedSenders, + emailIdentityExternal, + spaces } from '@u22n/database/schema'; import { and, eq, inArray, type InferInsertModel } from '@u22n/database/orm'; import { mailBridgeTrpcClient } from '~platform/utils/tRPCServerClients'; @@ -97,32 +98,29 @@ export const emailIdentityExternalRouter = router({ encryption: z.enum(['none', 'ssl', 'tls', 'starttls']), authMethod: z.enum(['plain', 'login']) }), - routeToOrgMemberPublicIds: z - .array(typeIdValidator('orgMembers')) - .optional(), - routeToTeamsPublicIds: z.array(typeIdValidator('teams')).optional() + routeToSpacesPublicIds: z.array(typeIdValidator('spaces')), + canSend: z.object({ + anyone: z.boolean(), + users: z.array(typeIdValidator('orgMembers')).optional(), + teams: z.array(typeIdValidator('teams')).optional() + }) }) ) .mutation(async ({ ctx, input }) => { const { db, org } = ctx; const orgId = org.id; - const { - sendName, - smtp, - routeToOrgMemberPublicIds, - routeToTeamsPublicIds, - emailAddress - } = input; + const { emailAddress, sendName, smtp, routeToSpacesPublicIds, canSend } = + input; const [emailUsername, emailDomain] = emailAddress.split('@') as [ string, string ]; - if (!routeToOrgMemberPublicIds && !routeToTeamsPublicIds) { + if (canSend.anyone && !canSend.users && !canSend.teams) { throw new TRPCError({ code: 'BAD_REQUEST', - message: 'Must route to at least one user or team' + message: 'At least one user or team must be allowed to send' }); } @@ -142,59 +140,6 @@ export const emailIdentityExternalRouter = router({ }); } - // get orgmembers and teams - const orgMemberObjects: { id: number; hasDefault: boolean }[] = []; - const orgMemberIdsResponse = - routeToOrgMemberPublicIds && routeToOrgMemberPublicIds.length > 0 - ? await db.query.orgMembers.findMany({ - where: inArray(orgMembers.publicId, routeToOrgMemberPublicIds), - columns: { - id: true - }, - with: { - authorizedEmailIdentities: { - columns: { - default: true - } - } - } - }) - : []; - orgMemberIdsResponse.forEach((orgMember) => { - orgMemberObjects.push({ - id: orgMember.id, - hasDefault: orgMember.authorizedEmailIdentities.some( - (identity) => identity.default - ) - }); - }); - - const userTeamObjects: { id: number; hasDefault: boolean }[] = []; - const userTeamIdsResponse = - routeToTeamsPublicIds && routeToTeamsPublicIds.length > 0 - ? await db.query.teams.findMany({ - where: inArray(teams.publicId, routeToTeamsPublicIds), - columns: { - id: true - }, - with: { - authorizedEmailIdentities: { - columns: { - default: true - } - } - } - }) - : []; - userTeamIdsResponse.forEach((userTeam) => { - userTeamObjects.push({ - id: userTeam.id, - hasDefault: userTeam.authorizedEmailIdentities.some( - (identity) => identity.default - ) - }); - }); - // create email routing rule const newRoutingRulePublicId = typeIdGenerator('emailRoutingRules'); const insertEmailRoutingRule = await db.insert(emailRoutingRules).values({ @@ -205,42 +150,7 @@ export const emailIdentityExternalRouter = router({ description: `Email routing rule for external address: ${emailUsername}@${emailDomain}` }); - type InsertRoutingRuleDestination = InferInsertModel< - typeof emailRoutingRulesDestinations - >; - // create email routing rule destinations - const routingRuleInsertValues: InsertRoutingRuleDestination[] = []; - if (orgMemberObjects.length > 0) { - orgMemberObjects.forEach((orgMemberObject) => { - const newRoutingRuleDestinationPublicId = typeIdGenerator( - 'emailRoutingRuleDestinations' - ); - routingRuleInsertValues.push({ - publicId: newRoutingRuleDestinationPublicId, - orgId: orgId, - ruleId: +insertEmailRoutingRule.insertId, - orgMemberId: orgMemberObject.id - }); - }); - } - if (userTeamObjects.length > 0) { - userTeamObjects.forEach((userTeamObject) => { - const newRoutingRuleDestinationPublicId = typeIdGenerator( - 'emailRoutingRuleDestinations' - ); - routingRuleInsertValues.push({ - publicId: newRoutingRuleDestinationPublicId, - orgId: orgId, - ruleId: +insertEmailRoutingRule.insertId, - teamId: userTeamObject.id - }); - }); - } - - await db - .insert(emailRoutingRulesDestinations) - .values(routingRuleInsertValues); - + // create email identity const emailIdentityPublicId = typeIdGenerator('emailIdentities'); const mailDomains = env.MAIL_DOMAINS; const fwdDomain = mailDomains.fwd[0]; @@ -261,40 +171,92 @@ export const emailIdentityExternalRouter = router({ isCatchAll: false }); - type InsertEmailIdentityAuthorizedOrgMembers = InferInsertModel< - typeof emailIdentitiesAuthorizedOrgMembers + // create email routing rule destinations + type InsertRoutingRuleDestination = InferInsertModel< + typeof emailRoutingRulesDestinations >; - const emailIdentityAuthorizedOrgMembersObjects: InsertEmailIdentityAuthorizedOrgMembers[] = + const routingRuleInsertValues: InsertRoutingRuleDestination[] = []; + + const destinationSpaceIdsResponse = await db.query.spaces.findMany({ + where: inArray(spaces.publicId, routeToSpacesPublicIds), + columns: { + id: true + } + }); + + destinationSpaceIdsResponse.forEach((space) => { + const newRoutingRuleDestinationPublicId = typeIdGenerator( + 'emailRoutingRuleDestinations' + ); + routingRuleInsertValues.push({ + publicId: newRoutingRuleDestinationPublicId, + orgId: orgId, + ruleId: +insertEmailRoutingRule.insertId, + spaceId: space.id + }); + }); + await db + .insert(emailRoutingRulesDestinations) + .values(routingRuleInsertValues); + + // create email Authorizations + type InsertEmailIdentityAuthorizedInsert = InferInsertModel< + typeof emailIdentitiesAuthorizedSenders + >; + const emailIdentityAuthorizedInsertObjects: InsertEmailIdentityAuthorizedInsert[] = []; - if (orgMemberObjects.length > 0) { - orgMemberObjects.forEach((orgMemberObject) => { - emailIdentityAuthorizedOrgMembersObjects.push({ + if (canSend.anyone) { + destinationSpaceIdsResponse.forEach((space) => { + emailIdentityAuthorizedInsertObjects.push({ orgId: orgId, identityId: +insertEmailIdentityResponse.insertId, addedBy: org.memberId, - orgMemberId: orgMemberObject.id, - default: !orgMemberObject.hasDefault + spaceId: space.id + }); + }); + } else { + const orgMemberIdsResponse = + canSend.users && canSend.users.length > 0 + ? await db.query.orgMembers.findMany({ + where: inArray(orgMembers.publicId, canSend.users), + columns: { + id: true + } + }) + : []; + orgMemberIdsResponse.forEach((orgMember) => { + emailIdentityAuthorizedInsertObjects.push({ + orgId: orgId, + identityId: +insertEmailIdentityResponse.insertId, + addedBy: org.memberId, + orgMemberId: orgMember.id }); }); - } - if (userTeamObjects.length > 0) { - userTeamObjects.forEach((userTeamObject) => { - emailIdentityAuthorizedOrgMembersObjects.push({ + const teamIdsResponse = + canSend.teams && canSend.teams.length > 0 + ? await db.query.teams.findMany({ + where: inArray(teams.publicId, canSend.teams), + columns: { + id: true + } + }) + : []; + teamIdsResponse.forEach((userTeam) => { + emailIdentityAuthorizedInsertObjects.push({ orgId: orgId, identityId: +insertEmailIdentityResponse.insertId, addedBy: org.memberId, - teamId: userTeamObject.id, - default: !userTeamObject.hasDefault + teamId: userTeam.id }); }); } - if (emailIdentityAuthorizedOrgMembersObjects.length > 0) { + if (emailIdentityAuthorizedInsertObjects.length > 0) { await db - .insert(emailIdentitiesAuthorizedOrgMembers) - .values(emailIdentityAuthorizedOrgMembersObjects); + .insert(emailIdentitiesAuthorizedSenders) + .values(emailIdentityAuthorizedInsertObjects); } const emailIdentityExternalPublicId = typeIdGenerator( diff --git a/apps/platform/trpc/routers/orgRouter/mail/emailIdentityRouter.ts b/apps/platform/trpc/routers/orgRouter/mail/emailIdentityRouter.ts index 3fc98291..5a62e9c9 100644 --- a/apps/platform/trpc/routers/orgRouter/mail/emailIdentityRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/mail/emailIdentityRouter.ts @@ -6,7 +6,9 @@ import { emailRoutingRulesDestinations, emailIdentities, teamMembers, - emailIdentitiesAuthorizedOrgMembers + spaces, + emailIdentitiesAuthorizedSenders, + convos } from '@u22n/database/schema'; import { and, @@ -21,6 +23,7 @@ import { type TypeId } from '@u22n/utils/typeid'; import { router, orgProcedure, orgAdminProcedure } from '~platform/trpc/trpc'; +import { spaceMembers } from './../../../../../../packages/database/schema'; import { emailIdentityExternalRouter } from './emailIdentityExternalRouter'; import { nanoIdToken } from '@u22n/utils/zodSchemas'; import { TRPCError } from '@trpc/server'; @@ -99,10 +102,12 @@ export const emailIdentityRouter = router({ domainPublicId: typeIdValidator('domains'), sendName: z.string().min(2).max(255), catchAll: z.boolean().optional().default(false), - routeToOrgMemberPublicIds: z - .array(typeIdValidator('orgMembers')) - .optional(), - routeToTeamsPublicIds: z.array(typeIdValidator('teams')).optional() + routeToSpacesPublicIds: z.array(typeIdValidator('spaces')), + canSend: z.object({ + anyone: z.boolean(), + users: z.array(typeIdValidator('orgMembers')).optional(), + teams: z.array(typeIdValidator('teams')).optional() + }) }) ) .mutation(async ({ ctx, input }) => { @@ -112,16 +117,18 @@ export const emailIdentityRouter = router({ domainPublicId, sendName, catchAll, - routeToOrgMemberPublicIds, - routeToTeamsPublicIds + routeToSpacesPublicIds, + canSend } = input; const emailUsername = input.emailUsername.toLowerCase(); - if (!routeToOrgMemberPublicIds && !routeToTeamsPublicIds) { + // pre-checks + + if (canSend.anyone && !canSend.users && !canSend.teams) { throw new TRPCError({ code: 'BAD_REQUEST', - message: 'Must route to at least one user or team' + message: 'At least one user or team must be allowed to send' }); } @@ -149,57 +156,24 @@ export const emailIdentityRouter = router({ }); } - const orgMemberObjects: { id: number; hasDefault: boolean }[] = []; - const orgMemberIdsResponse = - routeToOrgMemberPublicIds && routeToOrgMemberPublicIds.length > 0 - ? await db.query.orgMembers.findMany({ - where: inArray(orgMembers.publicId, routeToOrgMemberPublicIds), - columns: { - id: true - }, - with: { - authorizedEmailIdentities: { - columns: { - default: true - } - } - } - }) - : []; - orgMemberIdsResponse.forEach((orgMember) => { - orgMemberObjects.push({ - id: orgMember.id, - hasDefault: orgMember.authorizedEmailIdentities.some( - (identity) => identity.default - ) - }); + // verify email address is not already used + const emailIdentityResponse = await db.query.emailIdentities.findFirst({ + where: and( + eq(emailIdentities.username, emailUsername), + eq(emailIdentities.domainId, domainResponse.id), + eq(emailIdentities.orgId, orgId) + ), + columns: { + id: true + } }); - const userTeamObjects: { id: number; hasDefault: boolean }[] = []; - const userTeamIdsResponse = - routeToTeamsPublicIds && routeToTeamsPublicIds.length > 0 - ? await db.query.teams.findMany({ - where: inArray(teams.publicId, routeToTeamsPublicIds), - columns: { - id: true - }, - with: { - authorizedEmailIdentities: { - columns: { - default: true - } - } - } - }) - : []; - userTeamIdsResponse.forEach((userTeam) => { - userTeamObjects.push({ - id: userTeam.id, - hasDefault: userTeam.authorizedEmailIdentities.some( - (identity) => identity.default - ) + if (emailIdentityResponse) { + throw new TRPCError({ + code: 'CONFLICT', + message: `Email address ${emailUsername + '@' + domainResponse.domain} already exists` }); - }); + } // create email routing rule const newRoutingRulePublicId = typeIdGenerator('emailRoutingRules'); @@ -211,43 +185,7 @@ export const emailIdentityRouter = router({ description: `Email routing rule for ${emailUsername}@${domainResponse.domain}` }); - type InsertRoutingRuleDestination = InferInsertModel< - typeof emailRoutingRulesDestinations - >; - // create email routing rule destinations - const routingRuleInsertValues: InsertRoutingRuleDestination[] = []; - if (orgMemberObjects.length > 0) { - orgMemberObjects.forEach((orgMemberObject) => { - const newRoutingRuleDestinationPublicId = typeIdGenerator( - 'emailRoutingRuleDestinations' - ); - routingRuleInsertValues.push({ - publicId: newRoutingRuleDestinationPublicId, - orgId: orgId, - ruleId: +insertEmailRoutingRule.insertId, - orgMemberId: orgMemberObject.id - }); - }); - } - if (userTeamObjects.length > 0) { - userTeamObjects.forEach((userTeamObject) => { - const newRoutingRuleDestinationPublicId = typeIdGenerator( - 'emailRoutingRuleDestinations' - ); - routingRuleInsertValues.push({ - publicId: newRoutingRuleDestinationPublicId, - orgId: orgId, - ruleId: +insertEmailRoutingRule.insertId, - teamId: userTeamObject.id - }); - }); - } - - await db - .insert(emailRoutingRulesDestinations) - .values(routingRuleInsertValues); - - // create address + // create email identity const emailIdentityPublicId = typeIdGenerator('emailIdentities'); const mailDomains = env.MAIL_DOMAINS; const fwdDomain = mailDomains.fwd[0]; @@ -267,40 +205,116 @@ export const emailIdentityRouter = router({ isCatchAll: catchAll }); - type InsertEmailIdentityAuthorizedOrgMembers = InferInsertModel< - typeof emailIdentitiesAuthorizedOrgMembers + // create email routing rule destinations + type InsertRoutingRuleDestination = InferInsertModel< + typeof emailRoutingRulesDestinations + >; + const routingRuleInsertValues: InsertRoutingRuleDestination[] = []; + + const destinationSpaceIdsResponse = await db.query.spaces.findMany({ + where: inArray(spaces.publicId, routeToSpacesPublicIds), + columns: { + id: true + } + }); + + destinationSpaceIdsResponse.forEach((space) => { + const newRoutingRuleDestinationPublicId = typeIdGenerator( + 'emailRoutingRuleDestinations' + ); + routingRuleInsertValues.push({ + publicId: newRoutingRuleDestinationPublicId, + orgId: orgId, + ruleId: +insertEmailRoutingRule.insertId, + spaceId: space.id + }); + }); + await db + .insert(emailRoutingRulesDestinations) + .values(routingRuleInsertValues); + + // create email Authorizations + type InsertEmailIdentityAuthorizedInsert = InferInsertModel< + typeof emailIdentitiesAuthorizedSenders >; - const emailIdentityAuthorizedOrgMembersObjects: InsertEmailIdentityAuthorizedOrgMembers[] = + const emailIdentityAuthorizedInsertObjects: InsertEmailIdentityAuthorizedInsert[] = []; - if (orgMemberObjects.length > 0) { - orgMemberObjects.forEach((orgMemberObject) => { - emailIdentityAuthorizedOrgMembersObjects.push({ + if (canSend.anyone) { + destinationSpaceIdsResponse.forEach((space) => { + emailIdentityAuthorizedInsertObjects.push({ orgId: orgId, identityId: +insertEmailIdentityResponse.insertId, addedBy: org.memberId, - orgMemberId: orgMemberObject.id, - default: !orgMemberObject.hasDefault + spaceId: space.id }); }); - } + } else { + const orgMemberIdsResponse = + canSend.users && canSend.users.length > 0 + ? await db.query.orgMembers.findMany({ + where: inArray(orgMembers.publicId, canSend.users), + columns: { + id: true, + defaultEmailIdentityId: true + } + }) + : []; + + orgMemberIdsResponse.forEach((orgMember) => { + emailIdentityAuthorizedInsertObjects.push({ + orgId: orgId, + identityId: +insertEmailIdentityResponse.insertId, + addedBy: org.memberId, + orgMemberId: orgMember.id + }); + if (!orgMember.defaultEmailIdentityId) { + void db + .update(orgMembers) + .set({ + defaultEmailIdentityId: Number( + insertEmailIdentityResponse.insertId + ) + }) + .where(eq(orgMembers.id, orgMember.id)); + } + }); + + const teamIdsResponse = + canSend.teams && canSend.teams.length > 0 + ? await db.query.teams.findMany({ + where: inArray(teams.publicId, canSend.teams), + columns: { + id: true, + defaultEmailIdentityId: true + } + }) + : []; - if (userTeamObjects.length > 0) { - userTeamObjects.forEach((userTeamObject) => { - emailIdentityAuthorizedOrgMembersObjects.push({ + teamIdsResponse.forEach((team) => { + emailIdentityAuthorizedInsertObjects.push({ orgId: orgId, identityId: +insertEmailIdentityResponse.insertId, addedBy: org.memberId, - teamId: userTeamObject.id, - default: !userTeamObject.hasDefault + teamId: team.id }); + if (!team.defaultEmailIdentityId) { + void db + .update(teams) + .set({ + defaultEmailIdentityId: Number( + insertEmailIdentityResponse.insertId + ) + }) + .where(eq(teams.id, team.id)); + } }); } - if (emailIdentityAuthorizedOrgMembersObjects.length > 0) { + if (emailIdentityAuthorizedInsertObjects.length > 0) { await db - .insert(emailIdentitiesAuthorizedOrgMembers) - .values(emailIdentityAuthorizedOrgMembersObjects); + .insert(emailIdentitiesAuthorizedSenders) + .values(emailIdentityAuthorizedInsertObjects); } if (catchAll) { @@ -316,7 +330,6 @@ export const emailIdentityRouter = router({ emailIdentity: emailIdentityPublicId }; }), - getEmailIdentity: orgProcedure .input( z.object({ @@ -354,11 +367,11 @@ export const emailIdentityRouter = router({ domainStatus: true } }, - authorizedOrgMembers: { + authorizedSenders: { columns: { orgMemberId: true, teamId: true, - default: true + spaceId: true }, with: { orgMember: { @@ -382,6 +395,14 @@ export const emailIdentityRouter = router({ description: true, color: true } + }, + space: { + columns: { + publicId: true, + name: true, + description: true, + color: true + } } } }, @@ -394,6 +415,15 @@ export const emailIdentityRouter = router({ with: { destinations: { with: { + space: { + columns: { + publicId: true, + avatarTimestamp: true, + name: true, + description: true, + color: true + } + }, team: { columns: { publicId: true, @@ -458,6 +488,15 @@ export const emailIdentityRouter = router({ with: { destinations: { with: { + space: { + columns: { + publicId: true, + avatarTimestamp: true, + name: true, + description: true, + color: true + } + }, team: { columns: { publicId: true, @@ -490,181 +529,210 @@ export const emailIdentityRouter = router({ emailIdentityData: emailIdentityResponse }; }), - getUserEmailIdentities: orgProcedure.query(async ({ ctx }) => { - const { db, org } = ctx; - const orgId = org.id; - const orgMemberId = org?.memberId || 0; - // search for user org team memberships, get id of org team + getUserEmailIdentities: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64).nullable().optional(), + convoPublicId: typeIdValidator('convos').optional() + }) + ) + .query(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + const orgMemberId = org?.memberId || 0; + const authorizedSpaceIds: number[] = []; - const userOrgTeamMembershipQuery = await db.query.teamMembers.findMany({ - where: eq(teamMembers.orgMemberId, orgMemberId), - columns: { - teamId: true - }, - with: { - team: { + if (input.spaceShortcode) { + const spaceQueryResponse = await db.query.spaces.findFirst({ + where: and( + eq(spaces.orgId, orgId), + eq(spaces.shortcode, input.spaceShortcode) + ), columns: { - id: true, - orgId: true + id: true } + }); + if (spaceQueryResponse?.id) { + authorizedSpaceIds.push(spaceQueryResponse.id); } } - }); - const orgTeamIds = userOrgTeamMembershipQuery.filter( - (userOrgTeamMembership) => userOrgTeamMembership.team.orgId === orgId - ); + if (input.convoPublicId) { + const convoQueryResponse = await db.query.convos.findFirst({ + where: and( + eq(convos.publicId, input.convoPublicId), + eq(convos.orgId, orgId) + ), + columns: { + id: true + }, + with: { + spaces: { + columns: { + id: true, + spaceId: true + } + } + } + }); - const userTeamIds = orgTeamIds.map((orgTeamIds) => orgTeamIds.team.id); - const uniqueUserTeamIds = [...new Set(userTeamIds)]; + if (convoQueryResponse?.spaces) { + convoQueryResponse.spaces.map((space) => { + authorizedSpaceIds.push(space.id); + }); + } + } + + // get all space memberships for the orgMember + if (!input.spaceShortcode) { + const spaceMemberships = await db.query.spaceMembers.findMany({ + where: eq(spaceMembers.orgMemberId, orgMemberId), + columns: { + spaceId: true + } + }); + const orgOpenSpaces = await db.query.spaces.findMany({ + where: and(eq(spaces.orgId, orgId), eq(spaces.type, 'open')), + columns: { + id: true + } + }); - if (!uniqueUserTeamIds.length) { - uniqueUserTeamIds.push(0); - } + // create an array with unique spaceIds + const allUniqueSpaceIds = Array.from( + new Set( + spaceMemberships + .map((spaceMembership) => spaceMembership.spaceId) + .concat(orgOpenSpaces.map((space) => space.id)) + ) + ); + authorizedSpaceIds.push(...allUniqueSpaceIds); + } - // search email routingRulesDestinations for orgMemberId or orgTeamId + // search for user org team memberships, get id of org team - const authorizedEmailIdentities = - await db.query.emailIdentitiesAuthorizedOrgMembers.findMany({ - where: or( - eq(emailIdentitiesAuthorizedOrgMembers.orgMemberId, orgMemberId), - inArray(emailIdentitiesAuthorizedOrgMembers.teamId, uniqueUserTeamIds) + const userOrgTeamMembershipQuery = await db.query.teamMembers.findMany({ + where: and( + eq(teamMembers.orgId, orgId), + eq(teamMembers.orgMemberId, orgMemberId) ), columns: { - orgMemberId: true, - teamId: true, - default: true + teamId: true }, with: { - emailIdentity: { + team: { columns: { - publicId: true, - username: true, - domainName: true, - sendName: true - }, - with: { - domain: { - columns: { - domainStatus: true, - sendingMode: true - } - } + id: true, + orgId: true } } } }); - if (!authorizedEmailIdentities.length) { - return { - emailIdentities: [], - defaultEmailIdentity: undefined - }; - } - const defaultEmailIdentityPublicId: TypeId<'emailIdentities'> | undefined = - authorizedEmailIdentities.find((emailIdentityAuthorization) => - emailIdentityAuthorization.default && - emailIdentityAuthorization.emailIdentity?.publicId && - emailIdentityAuthorization.emailIdentity.domain - ? emailIdentityAuthorization.emailIdentity.domain.domainStatus === - 'active' && - emailIdentityAuthorization.emailIdentity.domain.sendingMode !== - 'disabled' - : true - )?.emailIdentity.publicId; - - const emailIdentities = authorizedEmailIdentities - .map((emailIdentityAuthorization) => { - const emailIdentity = emailIdentityAuthorization.emailIdentity; - const sendingEnabled = emailIdentity?.domain - ? emailIdentity.domain.domainStatus === 'active' && - emailIdentity.domain.sendingMode !== 'disabled' - : true; - return { - publicId: emailIdentity.publicId, - username: emailIdentity.username, - domainName: emailIdentity.domainName, - sendName: emailIdentity.sendName, - sendingEnabled - }; - }) - .filter( - (identity, index, self) => - index === self.findIndex((t) => t.publicId === identity.publicId) + const orgTeamIds = userOrgTeamMembershipQuery.filter( + (userOrgTeamMembership) => userOrgTeamMembership.team.orgId === orgId ); - return { - emailIdentities: emailIdentities, - defaultEmailIdentity: defaultEmailIdentityPublicId - }; - }), - userHasEmailIdentities: orgProcedure.query(async ({ ctx }) => { - const { db, org } = ctx; - const orgId = org.id; - const orgMemberId = org?.memberId || 0; - // search for user org team memberships, get id of org team + const userTeamIds = orgTeamIds.map((orgTeamIds) => orgTeamIds.team.id); + const uniqueUserTeamIds = [...new Set(userTeamIds)]; - const userOrgTeamMembershipQuery = await db.query.teamMembers.findMany({ - where: eq(teamMembers.orgMemberId, orgMemberId), - columns: { - teamId: true - }, - with: { - team: { - columns: { - id: true, - orgId: true - } - } + if (!uniqueUserTeamIds.length) { + uniqueUserTeamIds.push(0); } - }); - - const orgTeamIds = userOrgTeamMembershipQuery.filter( - (userOrgTeamMembership) => userOrgTeamMembership.team.orgId === orgId - ); - - const userTeamIds = orgTeamIds.map((orgTeamIds) => orgTeamIds.team.id); - const uniqueUserTeamIds = [...new Set(userTeamIds)]; - if (!uniqueUserTeamIds.length) { - uniqueUserTeamIds.push(0); - } + // search email routingRulesDestinations for spaceId or orgMemberId or orgTeamId + + const authorizedEmailIdentities = + await db.query.emailIdentitiesAuthorizedSenders.findMany({ + where: or( + authorizedSpaceIds.length && authorizedSpaceIds.length > 0 + ? inArray( + emailIdentitiesAuthorizedSenders.spaceId, + authorizedSpaceIds + ) + : undefined, + eq(emailIdentitiesAuthorizedSenders.orgMemberId, orgMemberId), + inArray(emailIdentitiesAuthorizedSenders.teamId, uniqueUserTeamIds) + ), + columns: { + orgMemberId: true, + teamId: true, + spaceId: true + }, + with: { + emailIdentity: { + columns: { + id: true, + publicId: true, + username: true, + domainName: true, + sendName: true + }, + with: { + domain: { + columns: { + domainStatus: true, + sendingMode: true + } + } + } + } + } + }); - // search email routingRulesDestinations for orgMemberId or orgTeamId + if (!authorizedEmailIdentities.length) { + return { + emailIdentities: [], + defaultEmailIdentity: undefined + }; + } - const authorizedEmailIdentities = - await db.query.emailIdentitiesAuthorizedOrgMembers.findMany({ - where: or( - eq(emailIdentitiesAuthorizedOrgMembers.orgMemberId, orgMemberId), - inArray( - emailIdentitiesAuthorizedOrgMembers.teamId, - uniqueUserTeamIds || [0] - ) - ), + const orgMemberQueryResponse = await db.query.orgMembers.findFirst({ + where: eq(orgMembers.id, orgMemberId), columns: { - orgMemberId: true, - teamId: true, - default: true - }, - with: { - emailIdentity: { - columns: { - publicId: true, - username: true, - domainName: true, - sendName: true - } - } + defaultEmailIdentityId: true } }); - if (!authorizedEmailIdentities.length) { + let defaultEmailIdentityPublicId: TypeId<'emailIdentities'> | undefined; + if ( + orgMemberQueryResponse && + orgMemberQueryResponse.defaultEmailIdentityId !== undefined + ) { + defaultEmailIdentityPublicId = + authorizedEmailIdentities.find( + (emailIdentityAuthorization) => + emailIdentityAuthorization.emailIdentity.id === + orgMemberQueryResponse.defaultEmailIdentityId + )?.emailIdentity.publicId ?? undefined; + } else { + defaultEmailIdentityPublicId = undefined; + } + + const emailIdentities = authorizedEmailIdentities + .map((emailIdentityAuthorization) => { + const emailIdentity = emailIdentityAuthorization.emailIdentity; + const sendingEnabled = emailIdentity?.domain + ? emailIdentity.domain.domainStatus === 'active' && + emailIdentity.domain.sendingMode !== 'disabled' + : true; + + return { + publicId: emailIdentity.publicId, + username: emailIdentity.username, + domainName: emailIdentity.domainName, + sendName: emailIdentity.sendName, + sendingEnabled + }; + }) + .filter( + (identity, index, self) => + index === self.findIndex((t) => t.publicId === identity.publicId) + ); + return { - hasIdentity: false + emailIdentities: emailIdentities, + defaultEmailIdentity: defaultEmailIdentityPublicId }; - } - return { - hasIdentity: true - }; - }) + }) }); diff --git a/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts b/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts index 208f9603..3ce463b7 100644 --- a/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts @@ -2,10 +2,13 @@ import { orgs, orgMembers, orgMemberProfiles, - accounts + accounts, + spaces, + spaceMembers } from '@u22n/database/schema'; import { blockedUsernames, reservedUsernames } from '~platform/utils/signup'; import { router, accountProcedure } from '~platform/trpc/trpc'; +import { validateSpaceShortCode } from '../spaceRouter/utils'; import { typeIdGenerator } from '@u22n/utils/typeid'; import { eq, and, like } from '@u22n/database/orm'; import type { DBType } from '@u22n/database'; @@ -137,17 +140,15 @@ export const crudRouter = router({ }); } - const newPublicId = typeIdGenerator('org'); + const newOrgPublicId = typeIdGenerator('org'); const insertOrgResponse = await db.insert(orgs).values({ ownerId: accountId, name: input.orgName, shortcode: input.orgShortcode, - publicId: newPublicId + publicId: newOrgPublicId }); - const orgId = +insertOrgResponse.insertId; - - const newProfilePublicId = typeIdGenerator('orgMemberProfile'); + const orgId = Number(insertOrgResponse.insertId); const { username } = (await db.query.accounts.findFirst({ @@ -168,7 +169,7 @@ export const crudRouter = router({ .insert(orgMemberProfiles) .values({ orgId: orgId, - publicId: newProfilePublicId, + publicId: typeIdGenerator('orgMemberProfile'), accountId: accountId, firstName: username, lastName: '', @@ -178,7 +179,7 @@ export const crudRouter = router({ }); const newOrgMemberPublicId = typeIdGenerator('orgMembers'); - await db.insert(orgMembers).values({ + const orgMemberResponse = await db.insert(orgMembers).values({ orgId: orgId, publicId: newOrgMemberPublicId, role: 'admin', @@ -187,8 +188,53 @@ export const crudRouter = router({ orgMemberProfileId: Number(newOrgMemberProfileInsert.insertId) }); + const spaceShortcode = await validateSpaceShortCode({ + db: db, + shortcode: `${username}`, + orgId: orgId + }); + const newSpaceResponse = await db.insert(spaces).values({ + orgId: orgId, + publicId: typeIdGenerator('spaces'), + name: `${username}'s Personal Space`, + type: 'private', + personalSpace: true, + color: 'cyan', + icon: 'house', + createdByOrgMemberId: Number(orgMemberResponse.insertId), + shortcode: spaceShortcode.shortcode + }); + + await db.insert(spaceMembers).values({ + orgId: orgId, + spaceId: Number(newSpaceResponse.insertId), + publicId: typeIdGenerator('spaceMembers'), + orgMemberId: Number(orgMemberResponse.insertId), + addedByOrgMemberId: Number(orgMemberResponse.insertId), + role: 'admin', + canCreate: true, + canRead: true, + canComment: true, + canReply: true, + canDelete: true, + canChangeWorkflow: true, + canSetWorkflowToClosed: true, + canAddTags: true, + canMoveToAnotherSpace: true, + canAddToAnotherSpace: true, + canMergeConvos: true, + canAddParticipants: true + }); + + await db + .update(orgMembers) + .set({ + personalSpaceId: Number(newSpaceResponse.insertId) + }) + .where(eq(orgMembers.id, Number(orgMemberResponse.insertId))); + return { - orgId: newPublicId, + orgId: newOrgPublicId, orgName: input.orgName }; }), diff --git a/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts b/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts index 2e2deccb..dcc335d8 100644 --- a/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts @@ -3,11 +3,13 @@ import { emailIdentities, emailRoutingRules, emailRoutingRulesDestinations, - emailIdentitiesAuthorizedOrgMembers, + emailIdentitiesAuthorizedSenders, orgInvitations, orgMembers, orgMemberProfiles, - accounts + accounts, + spaces, + spaceMembers } from '@u22n/database/schema'; import { router, @@ -20,6 +22,7 @@ import { refreshOrgShortcodeCache } from '~platform/utils/orgShortcode'; import { billingTrpcClient } from '~platform/utils/tRPCServerClients'; import { typeIdGenerator, typeIdValidator } from '@u22n/utils/typeid'; import { sendInviteEmail } from '~platform/utils/mail/transactional'; +import { validateSpaceShortCode } from '../../spaceRouter/utils'; import { nanoIdToken, zodSchemas } from '@u22n/utils/zodSchemas'; import { addOrgMemberToTeamHandler } from './teamsHandler'; import { ratelimiter } from '~platform/trpc/ratelimit'; @@ -81,9 +84,9 @@ export const invitesRouter = router({ const orgMemberProfileId = +orgMemberProfileResponse.insertId; // Insert orgMember - save ID - const orgMemberPublicId = typeIdGenerator('orgMembers'); - const orgMemberResponse = await db.insert(orgMembers).values({ - publicId: orgMemberPublicId, + const newOrgMemberPublicId = typeIdGenerator('orgMembers'); + const newOrgMemberResponse = await db.insert(orgMembers).values({ + publicId: newOrgMemberPublicId, orgId: orgId, invitedByOrgMemberId: orgMemberId, status: 'invited', @@ -91,13 +94,59 @@ export const invitesRouter = router({ orgMemberProfileId: orgMemberProfileId }); + const spaceShortcode = await validateSpaceShortCode({ + db: db, + shortcode: `${newOrgMember.firstName}${newOrgMember.lastName ? '-' + newOrgMember.lastName : ''}`, + orgId: orgId + }); + + const newSpaceResponse = await db.insert(spaces).values({ + orgId: orgId, + publicId: typeIdGenerator('spaces'), + name: 'Personal', + type: 'private', + personalSpace: true, + color: 'cyan', + icon: 'house', + createdByOrgMemberId: Number(newOrgMemberResponse.insertId), + shortcode: spaceShortcode.shortcode + }); + + await db.insert(spaceMembers).values({ + orgId: orgId, + spaceId: Number(newSpaceResponse.insertId), + publicId: typeIdGenerator('spaceMembers'), + orgMemberId: Number(newOrgMemberResponse.insertId), + addedByOrgMemberId: Number(newOrgMemberResponse.insertId), + role: 'admin', + canCreate: true, + canRead: true, + canComment: true, + canReply: true, + canDelete: true, + canChangeWorkflow: true, + canSetWorkflowToClosed: true, + canAddTags: true, + canMoveToAnotherSpace: true, + canAddToAnotherSpace: true, + canMergeConvos: true, + canAddParticipants: true + }); + + await db + .update(orgMembers) + .set({ + personalSpaceId: Number(newSpaceResponse.insertId) + }) + .where(eq(orgMembers.id, Number(newOrgMemberResponse.insertId))); + // Insert teamMemberships - save ID if (teamsInput) { for (const teamPublicId of teamsInput.teamsPublicIds) { await addOrgMemberToTeamHandler(db, { orgId: org.id, teamPublicId: teamPublicId, - orgMemberPublicId: orgMemberPublicId, + orgMemberPublicId: newOrgMemberPublicId, orgMemberId: org.memberId }); } @@ -136,7 +185,7 @@ export const invitesRouter = router({ publicId: newRoutingRuleDestinationPublicId, orgId: orgId, ruleId: +emailRoutingRulesResponse.insertId, - orgMemberId: +orgMemberResponse.insertId + spaceId: Number(newSpaceResponse.insertId) }); const emailIdentityPublicId = typeIdGenerator('emailIdentities'); @@ -158,13 +207,19 @@ export const invitesRouter = router({ sendName: email.sendName }); - await db.insert(emailIdentitiesAuthorizedOrgMembers).values({ + await db.insert(emailIdentitiesAuthorizedSenders).values({ orgId: orgId, - identityId: +emailIdentityResponse.insertId, - default: true, + identityId: Number(emailIdentityResponse.insertId), addedBy: orgMemberId, - orgMemberId: +orgMemberResponse.insertId + spaceId: Number(newSpaceResponse.insertId) }); + + await db + .update(orgMembers) + .set({ + personalSpaceId: Number(newSpaceResponse.insertId) + }) + .where(eq(orgMembers.id, Number(newOrgMemberResponse.insertId))); } // Insert orgInvitations - save ID @@ -176,7 +231,7 @@ export const invitesRouter = router({ publicId: newInvitePublicId, orgId: orgId, invitedByOrgMemberId: orgMemberId, - orgMemberId: +orgMemberResponse.insertId, + orgMemberId: +newOrgMemberResponse.insertId, role: newOrgMember.role, email: notification?.notificationEmailAddress ?? null, inviteToken: newInviteToken, diff --git a/apps/platform/trpc/routers/orgRouter/users/teamsHandler.ts b/apps/platform/trpc/routers/orgRouter/users/teamsHandler.ts index ddfa1614..bb0c3ddd 100644 --- a/apps/platform/trpc/routers/orgRouter/users/teamsHandler.ts +++ b/apps/platform/trpc/routers/orgRouter/users/teamsHandler.ts @@ -1,10 +1,4 @@ -import { - convoParticipantTeamMembers, - convoParticipants, - teamMembers, - teams, - orgMembers -} from '@u22n/database/schema'; +import { teamMembers, teams, orgMembers } from '@u22n/database/schema'; import { typeIdGenerator, type TypeId } from '@u22n/utils/typeid'; import { and, eq } from '@u22n/database/orm'; import type { DBType } from '@u22n/database'; @@ -72,63 +66,63 @@ export async function addOrgMemberToTeamHandler( }); } - const teamParticipationConvoIdsQuery = - await db.query.convoParticipants.findMany({ - columns: { - convoId: true - }, - where: and( - eq(convoParticipants.orgId, orgId), - eq(convoParticipants.teamId, teamQuery.id) - ) - }); + // const teamParticipationConvoIdsQuery = + // await db.query.convoParticipants.findMany({ + // columns: { + // convoId: true + // }, + // where: and( + // eq(convoParticipants.orgId, orgId), + // eq(convoParticipants.teamId, teamQuery.id) + // ) + // }); - const teamParticipationConvoIds = teamParticipationConvoIdsQuery.map( - (convo) => convo.convoId - ); + // const teamParticipationConvoIds = teamParticipationConvoIdsQuery.map( + // (convo) => convo.convoId + // ); - if (teamParticipationConvoIds.length > 0) { - for (const convoId of teamParticipationConvoIds) { - const convoParticipantPublicId = typeIdGenerator('convoParticipants'); - let convoParticipantId: number | undefined; - try { - const insertConvoParticipantResponse = await db - .insert(convoParticipants) - .values({ - orgId: orgId, - publicId: convoParticipantPublicId, - convoId: convoId, - orgMemberId: orgMember.id, - role: 'teamMember', - notifications: 'active' - }); - if (insertConvoParticipantResponse) { - convoParticipantId = Number(insertConvoParticipantResponse.insertId); - } - } catch (retry) { - const existingConvoParticipant = - await db.query.convoParticipants.findFirst({ - columns: { - id: true - }, - where: and( - eq(convoParticipants.orgId, orgId), - eq(convoParticipants.convoId, convoId), - eq(convoParticipants.orgMemberId, orgMember.id) - ) - }); - if (existingConvoParticipant) { - convoParticipantId = Number(existingConvoParticipant.id); - } - } - if (convoParticipantId) { - await db.insert(convoParticipantTeamMembers).values({ - convoParticipantId: Number(convoParticipantId), - teamId: teamQuery.id, - orgId: orgId - }); - } - } - } + // if (teamParticipationConvoIds.length > 0) { + // for (const convoId of teamParticipationConvoIds) { + // const convoParticipantPublicId = typeIdGenerator('convoParticipants'); + // let convoParticipantId: number | undefined; + // try { + // const insertConvoParticipantResponse = await db + // .insert(convoParticipants) + // .values({ + // orgId: orgId, + // publicId: convoParticipantPublicId, + // convoId: convoId, + // orgMemberId: orgMember.id, + // role: 'teamMember', + // notifications: 'active' + // }); + // if (insertConvoParticipantResponse) { + // convoParticipantId = Number(insertConvoParticipantResponse.insertId); + // } + // } catch (retry) { + // const existingConvoParticipant = + // await db.query.convoParticipants.findFirst({ + // columns: { + // id: true + // }, + // where: and( + // eq(convoParticipants.orgId, orgId), + // eq(convoParticipants.convoId, convoId), + // eq(convoParticipants.orgMemberId, orgMember.id) + // ) + // }); + // if (existingConvoParticipant) { + // convoParticipantId = Number(existingConvoParticipant.id); + // } + // } + // if (convoParticipantId) { + // await db.insert(convoParticipantTeamMembers).values({ + // convoParticipantId: Number(convoParticipantId), + // teamId: teamQuery.id, + // orgId: orgId + // }); + // } + // } + // } return newTeamMemberPublicId; } diff --git a/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts b/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts index c41f317b..b31eedea 100644 --- a/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/users/teamsRouter.ts @@ -1,7 +1,17 @@ +import { + teams, + spaces, + spaceMembers, + emailIdentities +} from '@u22n/database/schema'; +import { + typeIdGenerator, + typeIdValidator, + type TypeId +} from '@u22n/utils/typeid'; import { router, orgProcedure, orgAdminProcedure } from '~platform/trpc/trpc'; -import { typeIdGenerator, typeIdValidator } from '@u22n/utils/typeid'; +import { validateSpaceShortCode } from '../../spaceRouter/utils'; import { addOrgMemberToTeamHandler } from './teamsHandler'; -import { teams } from '@u22n/database/schema'; import { uiColors } from '@u22n/utils/colors'; import { eq, and } from '@u22n/database/orm'; import { TRPCError } from '@trpc/server'; @@ -13,26 +23,73 @@ export const teamsRouter = router({ z.object({ teamName: z.string().min(2).max(50), teamDescription: z.string().min(0).max(500).optional(), - teamColor: z.enum(uiColors) + teamColor: z.enum(uiColors), + createSpace: z.boolean().default(false) }) ) .mutation(async ({ ctx, input }) => { const { db, org } = ctx; const orgId = org.id; - const { teamName, teamDescription, teamColor } = input; - const newPublicId = typeIdGenerator('teams'); + const { teamName, teamDescription, teamColor, createSpace } = input; + const newTeamPublicId = typeIdGenerator('teams'); - await db.insert(teams).values({ - publicId: newPublicId, + // Create the new team + const newTeamResponse = await db.insert(teams).values({ + publicId: newTeamPublicId, name: teamName, description: teamDescription, color: teamColor, orgId: orgId }); + const newTeamId = newTeamResponse.insertId; + + let newSpacePublicId: TypeId<'spaces'> | undefined; + + if (createSpace) { + newSpacePublicId = typeIdGenerator('spaces'); + const newSpaceMemberPublicId = typeIdGenerator('spaceMembers'); + + const spaceShortcode = await validateSpaceShortCode({ + db: db, + shortcode: teamName, + orgId: orgId + }); + + // Create a space for the new team + const newSpaceResponse = await db.insert(spaces).values({ + publicId: newSpacePublicId, + orgId: Number(orgId), + name: teamName, + color: teamColor, + createdByOrgMemberId: org.memberId, + shortcode: spaceShortcode.shortcode, + type: 'private', + icon: 'squares-four' + }); + + const newSpaceId = newSpaceResponse.insertId; + + // Add the team to the space + await db.insert(spaceMembers).values({ + publicId: newSpaceMemberPublicId, + orgId: orgId, + spaceId: Number(newSpaceId), + teamId: Number(newTeamId), + addedByOrgMemberId: org.memberId, + role: 'admin' + }); + + await db + .update(teams) + .set({ defaultSpaceId: Number(newSpaceId) }) + .where(eq(teams.id, Number(newTeamId))); + } + return { - newTeamPublicId: newPublicId + newTeamPublicId: newTeamPublicId, + newSpacePublicId: newSpacePublicId }; }), getOrgTeams: orgProcedure.query(async ({ ctx }) => { @@ -130,23 +187,19 @@ export const teamsRouter = router({ } } }, - authorizedEmailIdentities: { - columns: {}, - with: { - emailIdentity: { - columns: { - username: true, - sendName: true, - domainName: true - } - } + defaultEmailIdentity: { + columns: { + username: true, + domainName: true, + sendName: true } } } }); return { - team: teamQuery + team: teamQuery, + defaultEmailIdentity: teamQuery?.defaultEmailIdentity }; }), addOrgMemberToTeam: orgAdminProcedure @@ -218,5 +271,66 @@ export const teamsRouter = router({ code: 'NOT_IMPLEMENTED', message: 'Not implemented' }); + }), + setTeamDefaultEmailIdentity: orgAdminProcedure + .input( + z.object({ + teamPublicId: typeIdValidator('teams'), + emailIdentityPublicId: typeIdValidator('emailIdentities') + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + + const orgId = org.id; + const { teamPublicId, emailIdentityPublicId } = input; + + // get then email identity id + const emailIdentityQueryResponse = + await db.query.emailIdentities.findFirst({ + where: and( + eq(emailIdentities.publicId, emailIdentityPublicId), + eq(emailIdentities.orgId, orgId) + ), + columns: { + id: true + } + }); + + if (!emailIdentityQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Email identity not found' + }); + } + + // get team to verify it exists + const teamQueryResponse = await db.query.teams.findFirst({ + where: and(eq(teams.publicId, teamPublicId), eq(teams.orgId, orgId)), + columns: { + id: true + } + }); + + if (!teamQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Team not found' + }); + } + + await db + .update(teams) + .set({ defaultEmailIdentityId: Number(emailIdentityQueryResponse.id) }) + .where( + and( + eq(teams.orgId, orgId), + eq(teams.id, Number(teamQueryResponse.id)) + ) + ); + + return { + success: true + }; }) }); diff --git a/apps/platform/trpc/routers/spaceRouter/membersRouter.ts b/apps/platform/trpc/routers/spaceRouter/membersRouter.ts new file mode 100644 index 00000000..e7fc6b64 --- /dev/null +++ b/apps/platform/trpc/routers/spaceRouter/membersRouter.ts @@ -0,0 +1,3 @@ +import { router } from '~platform/trpc/trpc'; + +export const spaceMembersRouter = router({}); diff --git a/apps/platform/trpc/routers/spaceRouter/spaceRouter.ts b/apps/platform/trpc/routers/spaceRouter/spaceRouter.ts new file mode 100644 index 00000000..c7909aba --- /dev/null +++ b/apps/platform/trpc/routers/spaceRouter/spaceRouter.ts @@ -0,0 +1,677 @@ +import { + orgMembers, + spaces, + spaceMembers, + teamMembers, + convos, + convoToSpaces, + convoEntries +} from '@u22n/database/schema'; +import { router, accountProcedure, orgProcedure } from '~platform/trpc/trpc'; +import { iCanHazCallerFactory } from '../orgRouter/iCanHaz/iCanHazRouter'; +import { isOrgMemberSpaceMember, validateSpaceShortCode } from './utils'; +import { typeIdGenerator, typeIdValidator } from '@u22n/utils/typeid'; +import { eq, and, inArray, or, desc, lt } from '@u22n/database/orm'; +import { spaceSettingsRouter } from './spaceSettingsRouter'; +import { spaceWorkflowsRouter } from './workflowsRouter'; +import { spaceMembersRouter } from './membersRouter'; +import { spaceTagsRouter } from './tagsRouter'; +import { uiColors } from '@u22n/utils/colors'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +export const spaceRouter = router({ + members: spaceMembersRouter, + workflows: spaceWorkflowsRouter, + tags: spaceTagsRouter, + settings: spaceSettingsRouter, + getAllOrgSpaces: orgProcedure.query(async ({ ctx }) => { + const orgSpaces = await ctx.db.query.spaces.findMany({ + where: eq(spaces.orgId, ctx.org.id), + columns: { + publicId: true, + shortcode: true, + name: true, + description: true, + type: true, + avatarTimestamp: true, + convoPrefix: true, + inheritParentPermissions: true, + color: true, + icon: true, + personalSpace: true + }, + with: { + parentSpace: { + columns: { + publicId: true + } + }, + subSpaces: { + columns: { + publicId: true + } + } + } + }); + return { + spaces: orgSpaces + }; + }), + getAllOrgSpacesWithPersonalSeparately: orgProcedure.query(async ({ ctx }) => { + const personalSpaces = await ctx.db.query.spaces.findMany({ + where: and(eq(spaces.orgId, ctx.org.id), eq(spaces.personalSpace, true)), + columns: { + publicId: true, + shortcode: true, + name: true, + description: true, + type: true, + avatarTimestamp: true, + convoPrefix: true, + color: true, + icon: true, + personalSpace: true + }, + with: { + personalSpaceOwner: { + columns: { + publicId: true + }, + with: { + profile: { + columns: { + publicId: true, + avatarTimestamp: true, + firstName: true, + lastName: true, + handle: true, + title: true + } + } + } + } + } + }); + + const orgSpaces = await ctx.db.query.spaces.findMany({ + where: and(eq(spaces.orgId, ctx.org.id), eq(spaces.personalSpace, false)), + columns: { + publicId: true, + shortcode: true, + name: true, + description: true, + type: true, + avatarTimestamp: true, + convoPrefix: true, + color: true, + icon: true + } + }); + + return { + personalSpaces: personalSpaces, + orgSpaces: orgSpaces + }; + }), + + getOrgMemberSpaces: orgProcedure.query(async ({ ctx }) => { + const { db, org } = ctx; + + // TODO: Optimize this query to run in one single db sql query rather than multiple + const memberSpaceIds: number[] = []; + + const userTeamMemberships = await db.query.teamMembers.findMany({ + where: and( + eq(teamMembers.orgId, org.id), + eq(teamMembers.orgMemberId, org.memberId) + ), + columns: { + teamId: true + } + }); + + if (userTeamMemberships.length > 0) { + const teamSpaces = await db.query.spaceMembers.findMany({ + where: and( + eq(spaceMembers.orgId, org.id), + inArray( + spaceMembers.teamId, + userTeamMemberships.map((teamMember) => teamMember.teamId) + ) + ), + columns: { + spaceId: true + } + }); + memberSpaceIds.push( + ...teamSpaces.map((teamMember) => teamMember.spaceId) + ); + } + + const orgMemberSpacesMemberships = await db.query.spaceMembers.findMany({ + where: and( + eq(spaceMembers.orgId, org.id), + eq(spaceMembers.orgMemberId, org.memberId) + ), + columns: { + spaceId: true + } + }); + if (orgMemberSpacesMemberships.length > 0) { + memberSpaceIds.push( + ...orgMemberSpacesMemberships.map((spaceMember) => spaceMember.spaceId) + ); + } + + const spaceIdsDedupe = Array.from(new Set([...memberSpaceIds])); + + const orgMemberSpaces = await db.query.spaces.findMany({ + where: and( + eq(spaces.orgId, org.id), + or(eq(spaces.type, 'open'), inArray(spaces.id, spaceIdsDedupe)) + ), + columns: { + publicId: true, + shortcode: true, + name: true, + description: true, + type: true, + avatarTimestamp: true, + convoPrefix: true, + inheritParentPermissions: true, + color: true, + icon: true, + personalSpace: true + }, + with: { + parentSpace: { + columns: { + publicId: true + } + }, + subSpaces: { + columns: { + publicId: true + } + } + } + }); + + const orgMemberPersonalSpaceQuery = await db.query.orgMembers.findFirst({ + where: eq(orgMembers.id, org.memberId), + columns: { + personalSpaceId: true + }, + with: { + personalSpace: { + columns: { + publicId: true + } + } + } + }); + + return { + spaces: orgMemberSpaces, + personalSpaceId: + orgMemberPersonalSpaceQuery?.personalSpace?.publicId ?? null + }; + }), + + createNewSpace: orgProcedure + .input( + z.object({ + spaceName: z.string().min(1).max(128), + spaceDescription: z.string().min(0).max(500).optional(), + spaceColor: z.enum(uiColors), + spaceType: z.enum(['open', 'private']) + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const { spaceName, spaceDescription, spaceColor, spaceType } = input; + + const iCanHazCaller = iCanHazCallerFactory(ctx); + + const canHazSpaces = await iCanHazCaller.space({ + orgShortcode: input.orgShortcode + }); + if ( + (spaceType === 'private' && !canHazSpaces.private) || + (spaceType === 'open' && !canHazSpaces.open) + ) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `You cannot create ${spaceType === 'private' ? 'a private' : 'an open'} on your current plan` + }); + } + + const newSpacePublicId = typeIdGenerator('spaces'); + + const validatedSpaceShortcode = await validateSpaceShortCode({ + db: db, + shortcode: spaceName, + orgId: org.id + }); + + const insertSpaceResponse = await db.insert(spaces).values({ + orgId: org.id, + publicId: newSpacePublicId, + name: spaceName, + type: spaceType, + color: spaceColor, + description: spaceDescription, + shortcode: validatedSpaceShortcode.shortcode, + createdByOrgMemberId: Number(org.memberId) + }); + + await db.insert(spaceMembers).values({ + orgId: org.id, + spaceId: Number(insertSpaceResponse.insertId), + publicId: typeIdGenerator('spaceMembers'), + orgMemberId: Number(org.memberId), + addedByOrgMemberId: Number(org.memberId), + role: 'admin' + }); + + return { + spacePublicId: newSpacePublicId, + spaceShortcode: validatedSpaceShortcode.shortcode + }; + }), + getSpaceDisplayProperties: orgProcedure + .input( + z.object({ + spaceShortcode: z.string() + }) + ) + .query(async ({ ctx, input }) => { + const { db } = ctx; + + if (input.spaceShortcode === 'all') { + return { + space: { + publicId: 'lala', + name: 'All Conversations', + avatarTimestamp: null, + color: 'cyan', + icon: 'squares-four' + } + }; + } + + if (input.spaceShortcode === 'personal') { + const orgMemberQuery = await db.query.orgMembers.findFirst({ + where: and( + eq(orgMembers.orgId, ctx.org.id), + eq(orgMembers.id, ctx.org.memberId) + ), + columns: { + id: true + }, + with: { + personalSpace: { + columns: { + publicId: true, + name: true, + avatarTimestamp: true, + color: true, + icon: true + } + } + } + }); + return { + space: orgMemberQuery?.personalSpace + }; + } + + const spaceQueryResponse = await db.query.spaces.findFirst({ + where: and( + eq(spaces.orgId, ctx.org.id), + eq(spaces.shortcode, input.spaceShortcode) + ), + columns: { + publicId: true, + name: true, + avatarTimestamp: true, + color: true, + icon: true + } + }); + + return { + space: spaceQueryResponse + }; + }), + + getAccountOrgs: accountProcedure + .input( + z.object({ + onlyAdmin: z.boolean().optional() + }) + ) + .query(async ({ ctx, input }) => { + const { db, account } = ctx; + const accountId = account.id; + + const whereAccountIsAdmin = input.onlyAdmin ?? false; + + const orgMembersQuery = await db.query.orgMembers.findMany({ + columns: { + role: true + }, + where: whereAccountIsAdmin + ? and( + eq(orgMembers.accountId, accountId), + eq(orgMembers.role, 'admin') + ) + : eq(orgMembers.accountId, accountId), + with: { + org: { + columns: { + publicId: true, + avatarTimestamp: true, + name: true, + shortcode: true + } + } + } + }); + + const adminOrgShortCodes = orgMembersQuery + .filter((orgMember) => orgMember.role === 'admin') + .map((orgMember) => orgMember.org.shortcode); + + return { + userOrgs: orgMembersQuery, + adminOrgShortCodes: adminOrgShortCodes + }; + }), + + getSpaceConvos: orgProcedure + .input( + z.object({ + spaceShortcode: z.string(), + // includeHidden: z.boolean().default(false), + cursor: z + .object({ + lastUpdatedAt: z.date().optional(), + lastPublicId: typeIdValidator('convos').optional() + }) + .default({}) + }) + ) + .query(async ({ ctx, input }) => { + const { db, org } = ctx; + const { spaceShortcode, cursor } = input; + const orgId = org.id; + + const LIMIT = 15; + + const inputLastUpdatedAt = cursor.lastUpdatedAt + ? new Date(cursor.lastUpdatedAt) + : new Date(); + + const inputLastPublicId = cursor.lastPublicId ?? 'c_'; + + const spaceIdsArray: number[] = []; + + if (spaceShortcode === 'personal') { + const orgMemberQuery = await db.query.orgMembers.findFirst({ + where: and( + eq(orgMembers.orgId, orgId), + eq(orgMembers.id, org.memberId) + ), + columns: { + id: true + }, + with: { + personalSpace: { + columns: { + id: true + } + } + } + }); + const spaceId = orgMemberQuery?.personalSpace?.id; + + if (!spaceId) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: + 'You dont have a personal Space set, please contact support' + }); + } + spaceIdsArray.push(spaceId); + } else if (spaceShortcode === 'all') { + const allOrgOpenSpaces = await db.query.spaces.findMany({ + where: and(eq(spaces.orgId, orgId), eq(spaces.type, 'open')), + columns: { + id: true + } + }); + + allOrgOpenSpaces.map((space) => spaceIdsArray.push(space.id)); + + const teamMemberships = await db.query.teamMembers.findMany({ + where: and( + eq(teamMembers.orgId, orgId), + eq(teamMembers.orgMemberId, org.memberId) + ), + columns: { + teamId: true + } + }); + const allTeamIds = Array.from( + new Set(teamMemberships.map((tm) => tm.teamId)) + ); + + const orgMemberSpaceMemberSpaces = await db.query.spaceMembers.findMany( + { + where: + allTeamIds.length === 0 + ? and( + eq(spaceMembers.orgId, orgId), + eq(spaceMembers.orgMemberId, org.memberId) + ) + : and( + eq(spaceMembers.orgId, orgId), + or( + eq(spaceMembers.orgMemberId, org.memberId), + inArray(spaceMembers.teamId, allTeamIds) + ) + ), + columns: { + spaceId: true + } + } + ); + + orgMemberSpaceMemberSpaces.map((space) => + spaceIdsArray.push(space.spaceId) + ); + } else { + const spaceMembership = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: spaceShortcode, + orgMemberId: org.memberId + }); + + if (!spaceMembership.role && spaceMembership.type !== 'open') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not a member of this space' + }); + } + + spaceIdsArray.push(spaceMembership.spaceId); + } + + if (spaceIdsArray.length === 0) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Space not found' + }); + } + + // Get all convos associated with the space(s) + const convoQueryDifferent = await db.query.convoToSpaces.findMany({ + where: inArray(convoToSpaces.spaceId, spaceIdsArray), + columns: { + convoId: true + } + }); + + const convoQuery = await db.query.convos.findMany({ + orderBy: [desc(convos.lastUpdatedAt), desc(convos.publicId)], + where: and( + inArray( + convos.id, + convoQueryDifferent.map((c) => c.convoId) + ), + or( + and( + eq(convos.lastUpdatedAt, inputLastUpdatedAt), + lt(convos.publicId, inputLastPublicId) + ), + lt(convos.lastUpdatedAt, inputLastUpdatedAt) + ) + ), + columns: { + publicId: true, + lastUpdatedAt: true + }, + limit: LIMIT + 1, + with: { + subjects: { + columns: { + subject: true + } + }, + participants: { + columns: { + role: true, + publicId: true, + hidden: true, + notifications: true + }, + with: { + orgMember: { + columns: { publicId: true }, + with: { + profile: { + columns: { + publicId: true, + firstName: true, + lastName: true, + avatarTimestamp: true, + handle: true + } + } + } + }, + team: { + columns: { + publicId: true, + name: true, + color: true, + avatarTimestamp: true + } + }, + contact: { + columns: { + publicId: true, + name: true, + avatarTimestamp: true, + setName: true, + emailUsername: true, + emailDomain: true, + type: true, + signaturePlainText: true, + signatureHtml: true + } + } + } + }, + entries: { + orderBy: [desc(convoEntries.createdAt)], + limit: 1, + columns: { + bodyPlainText: true, + type: true + }, + with: { + author: { + columns: { + publicId: true + }, + with: { + orgMember: { + columns: { + publicId: true + }, + with: { + profile: { + columns: { + publicId: true, + firstName: true, + lastName: true, + avatarTimestamp: true, + handle: true + } + } + } + }, + team: { + columns: { + publicId: true, + name: true, + color: true, + avatarTimestamp: true + } + }, + contact: { + columns: { + publicId: true, + name: true, + avatarTimestamp: true, + setName: true, + emailUsername: true, + emailDomain: true, + type: true + } + } + } + } + } + } + } + }); + + // As we fetch ${LIMIT + 1} convos at a time, if the length is <= ${LIMIT}, we know we've reached the end + if (convoQuery.length <= LIMIT) { + return { + data: convoQuery, + cursor: null + }; + } + + // If we have ${LIMIT + 1} convos, we pop the last one as we return ${LIMIT} convos + convoQuery.pop(); + + const newCursorLastUpdatedAt = + convoQuery[convoQuery.length - 1]!.lastUpdatedAt; + const newCursorLastPublicId = convoQuery[convoQuery.length - 1]!.publicId; + + return { + data: convoQuery, + cursor: { + lastUpdatedAt: newCursorLastUpdatedAt, + lastPublicId: newCursorLastPublicId + } + }; + }) +}); diff --git a/apps/platform/trpc/routers/spaceRouter/spaceSettingsRouter.ts b/apps/platform/trpc/routers/spaceRouter/spaceSettingsRouter.ts new file mode 100644 index 00000000..53eb94cf --- /dev/null +++ b/apps/platform/trpc/routers/spaceRouter/spaceSettingsRouter.ts @@ -0,0 +1,299 @@ +import { router, orgProcedure } from '~platform/trpc/trpc'; +import { z } from 'zod'; + +import { spaceTypeArray } from '@u22n/utils/spaces'; +import { isOrgMemberSpaceMember } from './utils'; +import { spaces } from '@u22n/database/schema'; +import { uiColors } from '@u22n/utils/colors'; +import { eq, and } from '@u22n/database/orm'; +import { TRPCError } from '@trpc/server'; + +export const spaceSettingsRouter = router({ + getSpacesSettings: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64) + }) + ) + .query(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (!spaceMembershipResponse.role) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not a member of this Space' + }); + } + + const spaceQueryResponse = await db.query.spaces.findFirst({ + where: and( + eq(spaces.orgId, ctx.org.id), + eq(spaces.shortcode, input.spaceShortcode) + ), + columns: { + publicId: true, + shortcode: true, + name: true, + description: true, + type: true, + avatarTimestamp: true, + convoPrefix: true, + inheritParentPermissions: true, + color: true, + icon: true, + personalSpace: true, + createdAt: true + }, + with: { + parentSpace: { + columns: { + publicId: true, + shortcode: true, + name: true, + description: true, + color: true, + icon: true, + avatarTimestamp: true + } + }, + subSpaces: { + columns: { + publicId: true, + shortcode: true, + name: true, + description: true, + color: true, + icon: true, + avatarTimestamp: true + } + }, + createdByOrgMember: { + columns: { + publicId: true + }, + with: { + profile: { + columns: { + publicId: true, + avatarTimestamp: true, + firstName: true, + lastName: true, + handle: true, + title: true, + blurb: true + } + } + } + } + } + }); + return { + settings: spaceQueryResponse, + role: spaceMembershipResponse.role + }; + }), + setSpaceName: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64), + spaceName: z.string().min(1).max(64) + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (spaceMembershipResponse.role !== 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have permission to edit this Space' + }); + } + + try { + await db + .update(spaces) + .set({ + name: input.spaceName + }) + .where( + and( + eq(spaces.orgId, orgId), + eq(spaces.id, spaceMembershipResponse.spaceId) + ) + ); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while updating Space name' + }); + } + + return { + success: true + }; + }), + setSpaceDescription: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64), + spaceDescription: z.string().min(1).max(64) + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (spaceMembershipResponse.role !== 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have permission to edit this Space' + }); + } + + try { + await db + .update(spaces) + .set({ + description: input.spaceDescription + }) + .where( + and( + eq(spaces.orgId, orgId), + eq(spaces.id, spaceMembershipResponse.spaceId) + ) + ); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while updating Space description' + }); + } + + return { + success: true + }; + }), + setSpaceColor: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64), + spaceColor: z.enum(uiColors) + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (spaceMembershipResponse.role !== 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have permission to edit this Space' + }); + } + + try { + await db + .update(spaces) + .set({ + color: input.spaceColor + }) + .where( + and( + eq(spaces.orgId, orgId), + eq(spaces.id, spaceMembershipResponse.spaceId) + ) + ); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while updating Space description' + }); + } + + return { + success: true + }; + }), + setSpaceType: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64), + spaceType: z.enum(spaceTypeArray) + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (spaceMembershipResponse.role !== 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have permission to edit this Space' + }); + } + + try { + await db + .update(spaces) + .set({ + type: input.spaceType + }) + .where( + and( + eq(spaces.orgId, orgId), + eq(spaces.id, spaceMembershipResponse.spaceId) + ) + ); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while updating Space description' + }); + } + + return { + success: true + }; + }) +}); diff --git a/apps/platform/trpc/routers/spaceRouter/tagsRouter.ts b/apps/platform/trpc/routers/spaceRouter/tagsRouter.ts new file mode 100644 index 00000000..47d0191c --- /dev/null +++ b/apps/platform/trpc/routers/spaceRouter/tagsRouter.ts @@ -0,0 +1,157 @@ +import { router, orgProcedure } from '~platform/trpc/trpc'; +import { z } from 'zod'; + +import { typeIdGenerator, typeIdValidator } from '@u22n/utils/typeid'; +import { spaces, spaceTags } from '@u22n/database/schema'; +import { isOrgMemberSpaceMember } from './utils'; +import { uiColors } from '@u22n/utils/colors'; +import { eq, and } from '@u22n/database/orm'; +import { TRPCError } from '@trpc/server'; + +export const spaceTagsRouter = router({ + getSpacesTags: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64) + }) + ) + .query(async ({ ctx, input }) => { + const spaceQueryResponse = await ctx.db.query.spaces.findFirst({ + where: and( + eq(spaces.orgId, ctx.org.id), + eq(spaces.shortcode, input.spaceShortcode) + ), + columns: { + id: true + } + }); + + if (!spaceQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Space not found' + }); + } + + const spaceTagsQueryResponse = await ctx.db.query.spaceTags.findMany({ + where: and( + eq(spaceTags.orgId, ctx.org.id), + eq(spaceTags.spaceId, spaceQueryResponse.id) + ), + columns: { + publicId: true, + label: true, + description: true, + icon: true, + color: true, + disabled: true + } + }); + + return spaceTagsQueryResponse; + }), + addNewSpaceTag: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64), + label: z.string().min(1).max(32), + color: z.enum(uiColors), + description: z.string().optional() + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (!spaceMembershipResponse.role) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have permission to add tags to this Space' + }); + } + + try { + await db.insert(spaceTags).values({ + orgId: orgId, + spaceId: spaceMembershipResponse.spaceId, + publicId: typeIdGenerator('spaceTags'), + label: input.label, + color: input.color, + description: input.description, + createdByOrgMemberId: org.memberId + }); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while creating the new Tag' + }); + } + + return { + success: true + }; + }), + editSpaceTag: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64), + spaceTagPublicId: typeIdValidator('spaceTags'), + label: z.string().min(1).max(32), + color: z.enum(uiColors), + description: z.string().optional() + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (!spaceMembershipResponse.role) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have permission to edit this Tag' + }); + } + + try { + await db + .update(spaceTags) + .set({ + label: input.label, + color: input.color, + description: input.description + }) + .where( + and( + eq(spaceTags.orgId, orgId), + eq(spaceTags.spaceId, spaceMembershipResponse.spaceId), + eq(spaceTags.publicId, input.spaceTagPublicId) + ) + ); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while saving the Tag changes' + }); + } + + return { + success: true + }; + }) +}); diff --git a/apps/platform/trpc/routers/spaceRouter/utils.ts b/apps/platform/trpc/routers/spaceRouter/utils.ts new file mode 100644 index 00000000..b3cbd50a --- /dev/null +++ b/apps/platform/trpc/routers/spaceRouter/utils.ts @@ -0,0 +1,419 @@ +import { + convos, + convoToSpaces, + convoWorkflows, + orgMembers, + spaces, + spaceWorkflows, + teamMembers +} from '@u22n/database/schema'; +import type { SpaceMemberRole, SpaceType } from '@u22n/utils/spaces'; +import { typeIdGenerator, type TypeId } from '@u22n/utils/typeid'; +import { eq, and, like, inArray } from '@u22n/database/orm'; +import { db, type DBType } from '@u22n/database'; +import { TRPCError } from '@trpc/server'; + +// Find a user's personal space +export async function personalSpaceLookup({ + db, + orgId, + accountId +}: { + db: DBType; + orgId: number; + accountId: number; +}): Promise<{ shortcode: string; spaceId: number } | null> { + const orgMemberQuery = await db.query.orgMembers.findFirst({ + where: and( + eq(orgMembers.orgId, orgId), + eq(orgMembers.accountId, accountId) + ), + columns: { + id: true + }, + with: { + personalSpace: { + columns: { + id: true, + shortcode: true + } + } + } + }); + + if (!orgMemberQuery?.personalSpace) { + return null; + } + + return { + spaceId: orgMemberQuery.personalSpace.id, + shortcode: orgMemberQuery.personalSpace.shortcode + }; +} + +export async function validateSpaceShortCode({ + db, + shortcode, + orgId, + spaceId +}: { + db: DBType; + shortcode: string; + orgId: number; + spaceId?: number; +}): Promise<{ + shortcode: string; +}> { + const cleanedShortcode = shortcode.toLowerCase().replace(/[^a-z0-9]/g, ''); + //check if the shortcode is the same as the existing space own current shortcode + if (spaceId) { + const existingSpace = await db.query.spaces.findFirst({ + where: and(eq(spaces.orgId, orgId), eq(spaces.id, spaceId)), + columns: { + id: true, + shortcode: true + } + }); + + if (existingSpace) { + if (existingSpace.shortcode === cleanedShortcode) { + return { + shortcode: cleanedShortcode + }; + } + } + } + + const existingSpaces = await db.query.spaces.findMany({ + where: and( + eq(spaces.orgId, orgId), + like(spaces.shortcode, `${cleanedShortcode}%`) + ), + columns: { + id: true, + shortcode: true + } + }); + + if (existingSpaces.length === 0) { + return { + shortcode: cleanedShortcode + }; + } + + const existingShortcodes = existingSpaces.map((space) => space.shortcode); + + let currentSuffix = existingSpaces.length; + let retries = 0; + let validatedShortcode = `${cleanedShortcode}${currentSuffix}`; + while (retries < 30) { + if (existingShortcodes.includes(validatedShortcode)) { + retries++; + currentSuffix++; + validatedShortcode = `${cleanedShortcode}${currentSuffix}`; + continue; + } + break; + } + + return { + shortcode: validatedShortcode + }; +} + +type IsOrgMemberSpaceMemberResponse = { + role: SpaceMemberRole | null; + spaceId: number; + type: SpaceType; + permissions: { + canCreate: boolean; + canRead: boolean; + canComment: boolean; + canReply: boolean; + canDelete: boolean; + canChangeWorkflow: boolean; + canSetWorkflowToClosed: boolean; + canAddTags: boolean; + canMoveToAnotherSpace: boolean; + canAddToAnotherSpace: boolean; + canMergeConvos: boolean; + canAddParticipants: boolean; + }; +}; + +export async function isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode, + orgMemberId +}: { + db: DBType; + orgId: number; + spaceShortcode: string; + orgMemberId: number; +}): Promise { + const spaceQueryResponse = await db.query.spaces.findFirst({ + where: and(eq(spaces.orgId, orgId), eq(spaces.shortcode, spaceShortcode)), + columns: { + id: true, + publicId: true, + type: true + }, + with: { + members: { + columns: { + id: true, + publicId: true, + role: true, + orgMemberId: true, + teamId: true, + canAddParticipants: true, + canAddTags: true, + canAddToAnotherSpace: true, + canChangeWorkflow: true, + canSetWorkflowToClosed: true, + canComment: true, + canCreate: true, + canDelete: true, + canMergeConvos: true, + canMoveToAnotherSpace: true, + canRead: true, + canReply: true + } + } + } + }); + + if (!spaceQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Space not found' + }); + } + + type SpaceMembership = (typeof spaceQueryResponse.members)[number]; + + const allSpaceMemberships: SpaceMembership[] = + spaceQueryResponse.members.filter( + (member) => member.orgMemberId === orgMemberId + ); + + const preTeamSpaceMemberships: SpaceMembership[] = + spaceQueryResponse.members.filter((member) => member.teamId !== null); + + if (preTeamSpaceMemberships.length > 0) { + const teamSpaceMembershipTeamIds = preTeamSpaceMemberships + .map((member) => member.teamId) + .filter((teamId) => teamId !== null); + const teamMemberQueryResponse = await db.query.teamMembers.findMany({ + where: and( + eq(teamMembers.orgId, orgId), + inArray(teamMembers.teamId, teamSpaceMembershipTeamIds), + eq(teamMembers.orgMemberId, orgMemberId) + ), + columns: { + teamId: true + } + }); + + if (teamMemberQueryResponse.length !== 0) { + const filteredTeamSpaceMemberships = preTeamSpaceMemberships.filter( + (preTeamSpaceMembership) => + teamMemberQueryResponse.find( + (teamMember) => teamMember.teamId === preTeamSpaceMembership.teamId + ) + ); + allSpaceMemberships.push(...filteredTeamSpaceMemberships); + } + } + + const spaceMembershipResponse: IsOrgMemberSpaceMemberResponse = { + role: null, + spaceId: spaceQueryResponse.id, + type: spaceQueryResponse.type, + permissions: { + canCreate: false, + canRead: false, + canComment: false, + canReply: false, + canDelete: false, + canChangeWorkflow: false, + canSetWorkflowToClosed: false, + canAddTags: false, + canMoveToAnotherSpace: false, + canAddToAnotherSpace: false, + canMergeConvos: false, + canAddParticipants: false + } + }; + + if (allSpaceMemberships.length !== 0) { + for (const spaceMembership of allSpaceMemberships) { + if (!spaceMembershipResponse.role || spaceMembership.role === 'admin') + spaceMembershipResponse.role = spaceMembership.role; + + // for each of the permissions, if the membership permission is true, set the permission to true else, set it to previous value + spaceMembershipResponse.permissions = { + canCreate: + spaceMembershipResponse.permissions.canCreate || + spaceMembership.canCreate, + canRead: + spaceMembershipResponse.permissions.canRead || + spaceMembership.canRead, + canComment: + spaceMembershipResponse.permissions.canComment || + spaceMembership.canComment, + canReply: + spaceMembershipResponse.permissions.canReply || + spaceMembership.canReply, + canDelete: + spaceMembershipResponse.permissions.canDelete || + spaceMembership.canDelete, + canChangeWorkflow: + spaceMembershipResponse.permissions.canChangeWorkflow || + spaceMembership.canChangeWorkflow, + canSetWorkflowToClosed: + spaceMembershipResponse.permissions.canSetWorkflowToClosed || + spaceMembership.canSetWorkflowToClosed, + canAddTags: + spaceMembershipResponse.permissions.canAddTags || + spaceMembership.canAddTags, + canMoveToAnotherSpace: + spaceMembershipResponse.permissions.canMoveToAnotherSpace || + spaceMembership.canMoveToAnotherSpace, + canAddToAnotherSpace: + spaceMembershipResponse.permissions.canAddToAnotherSpace || + spaceMembership.canAddToAnotherSpace, + canMergeConvos: + spaceMembershipResponse.permissions.canMergeConvos || + spaceMembership.canMergeConvos, + canAddParticipants: + spaceMembershipResponse.permissions.canAddParticipants || + spaceMembership.canAddParticipants + }; + } + } + + return spaceMembershipResponse; +} + +export async function verifySpaceMembership({ + orgId, + spacePublicId, + orgMemberId +}: { + spacePublicId: TypeId<'spaces'>; + orgId: number; + orgMemberId: number; +}) { + const space = await db.query.spaces.findFirst({ + where: and(eq(spaces.orgId, orgId), eq(spaces.publicId, spacePublicId)), + columns: { + shortcode: true + } + }); + if (!space) return false; + return isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: space.shortcode, + orgMemberId + }).then(({ permissions, role, type }) => { + return type === 'open' ? true : role === 'admin' || permissions.canRead; + }); +} + +export async function addConvoToSpace({ + db, + orgId, + convoId, + spaceId, + orgMemberId +}: { + db: DBType; + orgId: number; + convoId: number; + spaceId: number; + orgMemberId?: number; +}) { + // validate that the space and convo exist and belong to the same org + const spaceQuery = await db.query.spaces.findFirst({ + where: and(eq(spaces.orgId, orgId), eq(spaces.id, spaceId)), + columns: { + id: true, + createdByOrgMemberId: true + } + }); + if (!spaceQuery) { + throw new Error('❌addConvoToSpace: Space not found'); + } + const convoQuery = await db.query.convos.findFirst({ + where: and(eq(convos.orgId, orgId), eq(convos.id, convoId)), + columns: { + id: true + } + }); + if (!convoQuery) { + throw new Error('❌addConvoToSpace: Convo not found'); + } + + // check if the convo is already in the space + const convoToSpacesQuery = await db.query.convoToSpaces.findMany({ + where: and( + eq(convoToSpaces.orgId, orgId), + eq(convoToSpaces.convoId, convoId), + eq(convoToSpaces.spaceId, spaceId) + ), + columns: { + id: true + } + }); + if (convoToSpacesQuery.length > 0) { + return; + } + + // add the convo to the space + const newConvoToSpaceInsert = await db.insert(convoToSpaces).values({ + orgId: orgId, + convoId: convoId, + spaceId: spaceId, + publicId: typeIdGenerator('convoToSpaces') + }); + + // check if the space has "open" workflows + const spaceWorkflowsQuery = await db.query.spaceWorkflows.findMany({ + where: and( + eq(spaceWorkflows.orgId, orgId), + eq(spaceWorkflows.spaceId, spaceId), + eq(spaceWorkflows.type, 'open') + ), + columns: { + id: true, + disabled: true, + order: true + } + }); + + if (!spaceWorkflowsQuery || spaceWorkflowsQuery.length === 0) { + return; + } + + // check first convoWorkflow type === open + const openWorkflows = spaceWorkflowsQuery.sort((a, b) => a.order - b.order); + if (openWorkflows && openWorkflows.length > 0) { + const firstOpenWorkflow = openWorkflows?.[0]; + if (firstOpenWorkflow) { + await db.insert(convoWorkflows).values({ + orgId: orgId, + convoId: convoId, + spaceId: spaceId, + convoToSpaceId: Number(newConvoToSpaceInsert.insertId), + publicId: typeIdGenerator('convoWorkflows'), + workflow: firstOpenWorkflow.id, + byOrgMemberId: orgMemberId ?? spaceQuery.createdByOrgMemberId + }); + return; + } + } + return; +} diff --git a/apps/platform/trpc/routers/spaceRouter/workflowsRouter.ts b/apps/platform/trpc/routers/spaceRouter/workflowsRouter.ts new file mode 100644 index 00000000..b9dc3413 --- /dev/null +++ b/apps/platform/trpc/routers/spaceRouter/workflowsRouter.ts @@ -0,0 +1,411 @@ +import { router, orgProcedure } from '~platform/trpc/trpc'; +import { z } from 'zod'; + +import { convoWorkflows, spaces, spaceWorkflows } from '@u22n/database/schema'; +import { typeIdGenerator, typeIdValidator } from '@u22n/utils/typeid'; +import { spaceWorkflowTypeArray } from '@u22n/utils/spaces'; +import { isOrgMemberSpaceMember } from './utils'; +import { uiColors } from '@u22n/utils/colors'; +import { eq, and } from '@u22n/database/orm'; +import { TRPCError } from '@trpc/server'; + +export const spaceWorkflowsRouter = router({ + getSpacesWorkflows: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64) + }) + ) + .query(async ({ ctx, input }) => { + const spaceQueryResponse = await ctx.db.query.spaces.findFirst({ + where: and( + eq(spaces.orgId, ctx.org.id), + eq(spaces.shortcode, input.spaceShortcode) + ), + columns: { + id: true + } + }); + + if (!spaceQueryResponse) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Space not found' + }); + } + + const spaceWorkflowsQueryResponse = + await ctx.db.query.spaceWorkflows.findMany({ + where: and( + eq(spaceWorkflows.orgId, ctx.org.id), + eq(spaceWorkflows.spaceId, spaceQueryResponse.id) + ), + columns: { + publicId: true, + name: true, + description: true, + type: true, + icon: true, + color: true, + order: true, + disabled: true + } + }); + + const openWorkflows = spaceWorkflowsQueryResponse + .filter((workflow) => workflow.type === 'open') + .sort((a, b) => a.order - b.order); + const activeWorkflows = spaceWorkflowsQueryResponse + .filter((workflow) => workflow.type === 'active') + .sort((a, b) => a.order - b.order); + const closedWorkflows = spaceWorkflowsQueryResponse + .filter((workflow) => workflow.type === 'closed') + .sort((a, b) => a.order - b.order); + + return { + open: openWorkflows, + active: activeWorkflows, + closed: closedWorkflows + }; + }), + enableSpacesWorkflows: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64) + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + + const spaceLookup = await isOrgMemberSpaceMember({ + db, + orgId: org.id, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + // check if the space already has workflows + const spaceWorkflowsQueryResponse = + await ctx.db.query.spaceWorkflows.findMany({ + where: and( + eq(spaceWorkflows.orgId, ctx.org.id), + eq(spaceWorkflows.spaceId, spaceLookup.spaceId) + ), + columns: { + id: true + } + }); + + if (spaceWorkflowsQueryResponse.length > 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Space already has workflows' + }); + } + + type WorkflowInsert = typeof spaceWorkflows.$inferInsert; + const initialWorkflows: WorkflowInsert[] = [ + { + publicId: typeIdGenerator('spaceWorkflows'), + orgId: org.id, + spaceId: spaceLookup.spaceId, + name: 'New', + description: '', + type: 'open', + icon: 'circle', + color: 'blue', + order: 1, + disabled: false, + createdByOrgMemberId: org.memberId + }, + { + publicId: typeIdGenerator('spaceWorkflows'), + orgId: org.id, + spaceId: spaceLookup.spaceId, + name: 'In Progress', + description: '', + type: 'active', + icon: 'circle', + color: 'orange', + order: 1, + disabled: false, + createdByOrgMemberId: org.memberId + }, + { + publicId: typeIdGenerator('spaceWorkflows'), + orgId: org.id, + spaceId: spaceLookup.spaceId, + name: 'Completed', + description: '', + type: 'closed', + icon: 'circle', + color: 'jade', + order: 1, + disabled: false, + createdByOrgMemberId: org.memberId + } + ]; + + await db.insert(spaceWorkflows).values(initialWorkflows); + + return { + success: true + }; + }), + addNewSpaceWorkflow: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64), + type: z.enum(spaceWorkflowTypeArray), + order: z.number(), + name: z.string().min(1).max(32), + color: z.enum(uiColors), + description: z.string().optional() + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (spaceMembershipResponse.role !== 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have permission to edit this Space' + }); + } + + try { + await db.insert(spaceWorkflows).values({ + orgId: orgId, + spaceId: spaceMembershipResponse.spaceId, + publicId: typeIdGenerator('spaceWorkflows'), + type: input.type, + name: input.name, + color: input.color, + description: input.description, + order: input.order, + createdByOrgMemberId: org.memberId + }); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while creating the new Space Workflow' + }); + } + + return { + success: true + }; + }), + editSpaceWorkflow: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64), + spaceWorkflowPublicId: typeIdValidator('spaceWorkflows'), + name: z.string().min(1).max(32), + color: z.enum(uiColors), + description: z.string().optional() + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (spaceMembershipResponse.role !== 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have permission to edit this Space' + }); + } + + try { + await db + .update(spaceWorkflows) + .set({ + name: input.name, + color: input.color, + description: input.description + }) + .where( + and( + eq(spaceWorkflows.orgId, orgId), + eq(spaceWorkflows.spaceId, spaceMembershipResponse.spaceId), + eq(spaceWorkflows.publicId, input.spaceWorkflowPublicId) + ) + ); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while saving the Space Workflow changes' + }); + } + + return { + success: true + }; + }), + disableSpaceWorkflow: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64), + spaceWorkflowPublicId: typeIdValidator('spaceWorkflows'), + disable: z.boolean() + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (spaceMembershipResponse.role !== 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have permission to edit this Space' + }); + } + + try { + await db + .update(spaceWorkflows) + .set({ + disabled: input.disable + }) + .where( + and( + eq(spaceWorkflows.orgId, orgId), + eq(spaceWorkflows.spaceId, spaceMembershipResponse.spaceId), + eq(spaceWorkflows.publicId, input.spaceWorkflowPublicId) + ) + ); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while saving the Space Workflow changes' + }); + } + + return { + success: true + }; + }), + deleteSpaceWorkflow: orgProcedure + .input( + z.object({ + spaceShortcode: z.string().min(1).max(64), + spaceWorkflowPublicId: typeIdValidator('spaceWorkflows'), + replacementSpaceWorkflowPublicId: typeIdValidator('spaceWorkflows') + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, org } = ctx; + const orgId = org.id; + + const spaceMembershipResponse = await isOrgMemberSpaceMember({ + db, + orgId, + spaceShortcode: input.spaceShortcode, + orgMemberId: org.memberId + }); + + if (spaceMembershipResponse.role !== 'admin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You do not have permission to edit this Space' + }); + } + + const spaceWorkflowQueryResponse = + await db.query.spaceWorkflows.findFirst({ + where: and( + eq(spaceWorkflows.orgId, orgId), + eq(spaceWorkflows.publicId, input.spaceWorkflowPublicId), + eq(spaceWorkflows.spaceId, spaceMembershipResponse.spaceId) + ), + columns: { + id: true + } + }); + + if (!spaceWorkflowQueryResponse?.id) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'We cant find the Space Workflow, try again' + }); + } + + const replacementSpaceWorkflowQueryResponse = + await db.query.spaceWorkflows.findFirst({ + where: and( + eq(spaceWorkflows.orgId, orgId), + eq(spaceWorkflows.publicId, input.replacementSpaceWorkflowPublicId), + eq(spaceWorkflows.spaceId, spaceMembershipResponse.spaceId) + ), + columns: { + id: true + } + }); + + if (!replacementSpaceWorkflowQueryResponse?.id) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'We cant find the replacement Space Workflow, try again' + }); + } + + try { + await db + .update(convoWorkflows) + .set({ + workflow: replacementSpaceWorkflowQueryResponse.id + }) + .where( + and( + eq(convoWorkflows.orgId, orgId), + eq(convoWorkflows.workflow, spaceWorkflowQueryResponse.id) + ) + ); + + await db + .delete(spaceWorkflows) + .where( + and( + eq(spaceWorkflows.orgId, orgId), + eq(spaceWorkflows.id, spaceWorkflowQueryResponse.id) + ) + ); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Error while setting the replacement Space Workflow' + }); + } + + return { + success: true + }; + }) +}); diff --git a/apps/platform/trpc/routers/userRouter/addressRouter.ts b/apps/platform/trpc/routers/userRouter/addressRouter.ts index 015ff6a1..2ee07f57 100644 --- a/apps/platform/trpc/routers/userRouter/addressRouter.ts +++ b/apps/platform/trpc/routers/userRouter/addressRouter.ts @@ -3,10 +3,15 @@ import { emailIdentitiesPersonal, accounts, emailRoutingRules, - emailIdentitiesAuthorizedOrgMembers, + emailIdentitiesAuthorizedSenders, emailRoutingRulesDestinations } from '@u22n/database/schema'; -import { orgProcedure, router, accountProcedure } from '~platform/trpc/trpc'; +import { + orgProcedure, + router, + accountProcedure, + orgAdminProcedure +} from '~platform/trpc/trpc'; import { typeIdGenerator, typeIdValidator } from '@u22n/utils/typeid'; import { nanoIdToken } from '@u22n/utils/zodSchemas'; import { orgMembers } from '@u22n/database/schema'; @@ -156,7 +161,9 @@ export const addressRouter = router({ // get the account orgMemberProfile profile const accountOrgMembershipResponse = await db.query.orgMembers.findFirst({ where: eq(orgMembers.id, accountOrgMembership.id), - columns: {}, + columns: { + personalSpaceId: true + }, with: { profile: { columns: { @@ -202,8 +209,8 @@ export const addressRouter = router({ await db.insert(emailRoutingRulesDestinations).values({ publicId: newRoutingRuleDestinationPublicId, orgId: orgId, - ruleId: +routingRuleInsertResponse.insertId, - orgMemberId: accountOrgMembership.id + ruleId: Number(routingRuleInsertResponse.insertId), + spaceId: Number(accountOrgMembershipResponse.personalSpaceId) }); const newEmailIdentityPublicId = typeIdGenerator('emailIdentities'); @@ -243,11 +250,12 @@ export const addressRouter = router({ }) .where(eq(emailIdentities.id, +insertEmailIdentityResponse.insertId)); - await db.insert(emailIdentitiesAuthorizedOrgMembers).values({ + await db.insert(emailIdentitiesAuthorizedSenders).values({ orgId: orgId, addedBy: accountOrgMembership.id, identityId: +insertEmailIdentityResponse.insertId, - orgMemberId: accountOrgMembership.id + orgMemberId: accountOrgMembership.id, + spaceId: Number(accountOrgMembershipResponse.personalSpaceId) }); return { @@ -255,7 +263,7 @@ export const addressRouter = router({ emailIdentity: rootUserEmailAddress }; }), - editSendName: orgProcedure + editSendName: orgAdminProcedure .input( z.object({ emailIdentityPublicId: typeIdValidator('emailIdentities'), @@ -281,20 +289,6 @@ export const addressRouter = router({ where: eq(emailIdentities.publicId, input.emailIdentityPublicId), columns: { id: true - }, - with: { - authorizedOrgMembers: { - columns: { - orgMemberId: true - }, - with: { - orgMember: { - columns: { - id: true - } - } - } - } } }); @@ -304,16 +298,7 @@ export const addressRouter = router({ message: 'Email Identity not found' }); } - const authorizedOrgMembersIds = - emailIdentityResponse.authorizedOrgMembers.map( - (authorizedOrgMember) => authorizedOrgMember.orgMember?.id - ); - if (!authorizedOrgMembersIds.includes(accountOrgMembershipResponse.id)) { - throw new TRPCError({ - code: 'UNPROCESSABLE_CONTENT', - message: 'Org Member ID is not authorized' - }); - } + await db .update(emailIdentities) .set({ sendName: input.newSendName }) diff --git a/apps/platform/trpc/routers/userRouter/profileRouter.ts b/apps/platform/trpc/routers/userRouter/profileRouter.ts index 214593ae..8d0ca5be 100644 --- a/apps/platform/trpc/routers/userRouter/profileRouter.ts +++ b/apps/platform/trpc/routers/userRouter/profileRouter.ts @@ -1,5 +1,13 @@ -import { orgMemberProfiles, orgs, orgMembers } from '@u22n/database/schema'; +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + +import { + orgMemberProfiles, + orgs, + orgMembers, + spaces +} from '@u22n/database/schema'; import { router, accountProcedure } from '~platform/trpc/trpc'; +import { validateSpaceShortCode } from '../spaceRouter/utils'; import { typeIdValidator } from '@u22n/utils/typeid'; import { and, eq } from '@u22n/database/orm'; import { TRPCError } from '@trpc/server'; @@ -94,6 +102,33 @@ export const profileRouter = router({ input.name.split(' ').slice(-1).join(' ') ]; + const orgMemberProfileQuery = await db.query.orgMemberProfiles.findFirst({ + where: and( + eq(orgMemberProfiles.accountId, accountId), + eq(orgMemberProfiles.publicId, input.profilePublicId) + ), + columns: { + id: true, + orgId: true, + handle: true + }, + with: { + orgMember: { + columns: { + id: true, + personalSpaceId: true + } + } + } + }); + + if (!orgMemberProfileQuery) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Profile not found' + }); + } + await db .update(orgMemberProfiles) .set({ @@ -103,12 +138,27 @@ export const profileRouter = router({ blurb: input.blurb, handle: input.handle }) - .where( - and( - eq(orgMemberProfiles.publicId, input.profilePublicId), - eq(orgMemberProfiles.accountId, accountId) - ) - ); + .where(eq(orgMemberProfiles.id, orgMemberProfileQuery.id)); + + if (orgMemberProfileQuery.orgMember.personalSpaceId) { + const validatedShortcode = await validateSpaceShortCode({ + db: db, + shortcode: `${input.handle ?? orgMemberProfileQuery.handle}-personal`, + orgId: orgMemberProfileQuery.orgId, + spaceId: orgMemberProfileQuery.orgMember.personalSpaceId + }); + + await db + .update(spaces) + .set({ + name: `${firstName}'s Personal Space`, + shortcode: validatedShortcode.shortcode, + description: `${firstName}${lastName?.length > 0 ? ' ' + lastName : ''}'s Personal Space` + }) + .where( + eq(spaces.id, orgMemberProfileQuery.orgMember.personalSpaceId) + ); + } return { success: true diff --git a/apps/platform/trpc/routers/userRouter/securityRouter.ts b/apps/platform/trpc/routers/userRouter/securityRouter.ts index 0d597b60..4f16e514 100644 --- a/apps/platform/trpc/routers/userRouter/securityRouter.ts +++ b/apps/platform/trpc/routers/userRouter/securityRouter.ts @@ -15,7 +15,7 @@ import { convoSubjects, domains, emailIdentities, - emailIdentitiesAuthorizedOrgMembers, + emailIdentitiesAuthorizedSenders, emailIdentitiesPersonal, emailIdentityExternal, emailRoutingRules, @@ -1294,10 +1294,8 @@ export const securityRouter = router({ .delete(emailIdentities) .where(inArray(emailIdentities.orgId, orgIdsArray)), db - .delete(emailIdentitiesAuthorizedOrgMembers) - .where( - inArray(emailIdentitiesAuthorizedOrgMembers.orgId, orgIdsArray) - ), + .delete(emailIdentitiesAuthorizedSenders) + .where(inArray(emailIdentitiesAuthorizedSenders.orgId, orgIdsArray)), db .delete(emailIdentitiesPersonal) .where(inArray(emailIdentitiesPersonal.orgId, orgIdsArray)), diff --git a/apps/platform/trpc/trpc.ts b/apps/platform/trpc/trpc.ts index 31fac24b..6b487481 100644 --- a/apps/platform/trpc/trpc.ts +++ b/apps/platform/trpc/trpc.ts @@ -121,6 +121,54 @@ export const orgAdminProcedure = orgProcedure.use(async ({ ctx, next }) => { return next(); }); +// export const spaceProcedure = orgProcedure +// .input(z.object({ spaceShortcode: z.string() })) +// .use(({ input, ctx, next }) => +// ctx.event +// .get('otel') +// .tracer.startActiveSpan(`Validate orgShortCode`, async (span) => { +// const { orgShortCode } = input; +// span.setAttribute('org.shortCode', orgShortCode); +// const orgData = await validateOrgShortCode(orgShortCode); + +// if (!orgData) { +// span.setAttributes({ 'org.found': false }); +// span.end(); +// throw new TRPCError({ +// code: 'NOT_FOUND', +// message: 'Organization not found' +// }); +// } + +// const accountId = ctx.account.id; +// const orgMembership = orgData.members.find( +// (member) => member.accountId === accountId +// ); + +// if (!accountId || !orgMembership) { +// span.setAttributes({ 'org.is_member': false }); +// span.end(); +// ctx.event.header('Location', '/'); +// throw new TRPCError({ +// code: 'UNAUTHORIZED', +// message: 'You are not a member of this organization, redirecting...' +// }); +// } +// span.setAttributes({ +// 'org.found': true, +// 'org.is_member': true, +// 'org.member_id': orgMembership.id +// }); +// span.end(); +// return next({ +// ctx: { +// ...ctx, +// org: { ...orgData, memberId: orgMembership.id } +// } +// }); +// }) +// ); + export const turnstileProcedure = publicProcedure .input(z.object({ turnstileToken: z.string().optional() })) .use(async ({ input, ctx, next }) => { diff --git a/apps/platform/utils/realtime.ts b/apps/platform/utils/realtime.ts index 8299874b..ed7c0d76 100644 --- a/apps/platform/utils/realtime.ts +++ b/apps/platform/utils/realtime.ts @@ -1,7 +1,7 @@ -import { convoEntries, convoParticipants, convos } from '@u22n/database/schema'; +import { convoEntries, convos } from '@u22n/database/schema'; import RealtimeServer from '@u22n/realtime/server'; -import { eq, inArray } from '@u22n/database/orm'; import type { TypeId } from '@u22n/utils/typeid'; +import { eq } from '@u22n/database/orm'; import { db } from '@u22n/database'; import { env } from '~platform/env'; @@ -42,35 +42,46 @@ export async function sendRealtimeNotification({ } } } + }, + spaces: { + columns: {}, + with: { + space: { + columns: { + publicId: true + } + } + } } } }); - if (!convoQuery) { - return; - } + if (!convoQuery) return; const convoPublicId = convoQuery.publicId; - const orgMembersForNotificationPublicIds: TypeId<'orgMembers'>[] = []; - const orgMembersToUnhide: { - participantId: number; - orgMemberPublicId: TypeId<'orgMembers'>; - }[] = []; + const spacesForNotification = convoQuery.spaces.map( + (space) => space.space.publicId + ); + // const orgMembersForNotificationPublicIds: TypeId<'orgMembers'>[] = []; + // const orgMembersToUnhide: { + // participantId: number; + // orgMemberPublicId: TypeId<'orgMembers'>; + // }[] = []; let convoEntryPublicId: TypeId<'convoEntries'> | null = null; - convoQuery.participants.forEach((participant) => { - if (participant.orgMember) { - orgMembersForNotificationPublicIds.push(participant.orgMember.publicId); + // convoQuery.participants.forEach((participant) => { + // if (participant.orgMember) { + // orgMembersForNotificationPublicIds.push(participant.orgMember.publicId); - if (participant.hidden) { - orgMembersToUnhide.push({ - participantId: participant.id, - orgMemberPublicId: participant.orgMember.publicId - }); - } - } - }); + // if (participant.hidden) { + // orgMembersToUnhide.push({ + // participantId: participant.id, + // orgMemberPublicId: participant.orgMember.publicId + // }); + // } + // } + // }); if (!newConvo) { const convoEntryQuery = await db.query.convoEntries.findFirst({ @@ -85,50 +96,58 @@ export async function sendRealtimeNotification({ } if (newConvo || !convoEntryPublicId) { - await realtime - .emit({ - event: 'convo:new', - orgMemberPublicIds: orgMembersForNotificationPublicIds, - data: { - publicId: convoPublicId - } - }) - .catch(console.error); + await Promise.allSettled( + spacesForNotification.map( + async (spacePublicId) => + await realtime.emitOnChannels({ + channel: `private-space-${spacePublicId}`, + event: 'convo:new', + data: { + publicId: convoPublicId + } + }) + ) + ); } else { - await realtime - .emit({ - event: 'convo:entry:new', - orgMemberPublicIds: orgMembersForNotificationPublicIds, - data: { - convoPublicId, - convoEntryPublicId - } - }) - .catch(console.error); - if (orgMembersToUnhide.length > 0) { - const participantIds = orgMembersToUnhide.map( - (orgMember) => orgMember.participantId - ); - await db - .update(convoParticipants) - .set({ - hidden: false - }) - .where(inArray(convoParticipants.id, participantIds)); + await Promise.allSettled( + spacesForNotification.map( + async (spacePublicId) => + await realtime.emitOnChannels({ + channel: `private-space-${spacePublicId}`, + event: 'convo:entry:new', + data: { + convoPublicId, + convoEntryPublicId + } + }) + ) + ); - const orgMemberPublicIds = orgMembersToUnhide.map( - (orgMember) => orgMember.orgMemberPublicId - ); - await realtime - .emit({ - event: 'convo:hidden', - orgMemberPublicIds: orgMemberPublicIds, - data: { - publicId: convoPublicId, - hidden: false - } - }) - .catch(console.error); - } + // if (orgMembersToUnhide.length > 0) { + // const participantIds = orgMembersToUnhide.map( + // (orgMember) => orgMember.participantId + // ); + // await db + // .update(convoParticipants) + // .set({ + // hidden: false + // }) + // .where(inArray(convoParticipants.id, participantIds)); + + // const orgMemberPublicIds = orgMembersToUnhide.map( + // (orgMember) => orgMember.orgMemberPublicId + // ); + // await realtime + // .emit({ + // event: 'convo:hidden', + // orgMemberPublicIds: orgMemberPublicIds, + // data: { + // publicId: convoPublicId, + // hidden: false, + // spaceShortcode: spaceShortCodes + // } + // }) + // .catch(console.error); + // } } } diff --git a/apps/platform/utils/updateDnsRecords.ts b/apps/platform/utils/updateDnsRecords.ts index f5b752dc..f41a6ef5 100644 --- a/apps/platform/utils/updateDnsRecords.ts +++ b/apps/platform/utils/updateDnsRecords.ts @@ -223,7 +223,7 @@ export async function updateDnsRecords( }); await realtime.emit({ event: 'admin:issue:refresh', - data: null, + data: {}, orgMemberPublicIds: orgAdmins.map((_) => _.publicId) }); } diff --git a/apps/v1Migration/.gitignore b/apps/v1Migration/.gitignore new file mode 100644 index 00000000..7b987d03 --- /dev/null +++ b/apps/v1Migration/.gitignore @@ -0,0 +1 @@ +logs.txt diff --git a/apps/v1Migration/checkOrgConvos.ts b/apps/v1Migration/checkOrgConvos.ts new file mode 100644 index 00000000..ae9fadc2 --- /dev/null +++ b/apps/v1Migration/checkOrgConvos.ts @@ -0,0 +1,121 @@ +import { + orgs, + orgMembers, + convoParticipants, + teams as teamsSchema, + emailRoutingRulesDestinations +} from '@u22n/database/schema'; +import { eq } from '@u22n/database/orm'; +import { db } from '@u22n/database'; + +async function checkOrgConvos(orgId: number) { + console.info(`Checking data for org ID: ${orgId}`); + + // Get org details + const org = await db.query.orgs.findFirst({ + where: eq(orgs.id, orgId), + columns: { + id: true, + name: true, + ownerId: true + } + }); + + if (!org) { + console.info(`Organization with ID ${orgId} not found.`); + return; + } + + console.info(`Organization: ${org.name} (ID: ${org.id})`); + console.info(`Owner ID: ${org.ownerId}`); + + // Get org members + const members = await db.query.orgMembers.findMany({ + where: eq(orgMembers.orgId, orgId), + columns: { + id: true, + accountId: true + } + }); + + console.info(`Number of org members: ${members.length}`); + + // Check convos for each member + let totalConvos = 0; + for (const member of members) { + const convos = await db.query.convoParticipants.findMany({ + where: eq(convoParticipants.orgMemberId, member.id), + columns: { + convoId: true + } + }); + totalConvos += convos.length; + console.info( + `Member ID ${member.id} (Account ID: ${member.accountId}): ${convos.length} conversations` + ); + + // Check routing rule destinations for each member + const routingRules = await db.query.emailRoutingRulesDestinations.findMany({ + where: eq(emailRoutingRulesDestinations.orgMemberId, member.id), + columns: { + id: true + } + }); + console.info(` Routing rule destinations: ${routingRules.length}`); + } + + console.info(`Total conversations for org: ${totalConvos}`); + + // Get org teams + const orgTeams = await db.query.teams.findMany({ + where: eq(teamsSchema.orgId, orgId), + columns: { + id: true, + name: true + } + }); + + console.info(`Number of org teams: ${orgTeams.length}`); + + // Check convos for each team + let totalTeamConvos = 0; + for (const team of orgTeams) { + const convos = await db.query.convoParticipants.findMany({ + where: eq(convoParticipants.teamId, team.id), + columns: { + convoId: true + } + }); + totalTeamConvos += convos.length; + console.info( + `Team ID ${team.id} (${team.name}): ${convos.length} conversations` + ); + + // Check routing rule destinations for each team + const routingRules = await db.query.emailRoutingRulesDestinations.findMany({ + where: eq(emailRoutingRulesDestinations.teamId, team.id), + columns: { + id: true + } + }); + console.info(` Routing rule destinations: ${routingRules.length}`); + } + + console.info(`Total team conversations for org: ${totalTeamConvos}`); + console.info( + `Grand total conversations for org: ${totalConvos + totalTeamConvos}` + ); +} + +// Get org ID from command line argument +// @ts-expect-error it's a script +const orgIdToCheck = parseInt(process.argv[2], 10); + +if (isNaN(orgIdToCheck)) { + console.error( + 'Please provide a valid organization ID as a command-line argument.' + ); + process.exit(1); +} + +checkOrgConvos(orgIdToCheck).catch(console.error); diff --git a/apps/v1Migration/env.ts b/apps/v1Migration/env.ts new file mode 100644 index 00000000..3919dd7b --- /dev/null +++ b/apps/v1Migration/env.ts @@ -0,0 +1,19 @@ +import { createEnv } from '@t3-oss/env-core'; +import { z } from 'zod'; + +export const env = createEnv({ + server: { + WEBAPP_URL: z.string().url(), + STORAGE_KEY: z.string().min(1), + STORAGE_S3_ENDPOINT: z.string().min(1), + STORAGE_S3_REGION: z.string().min(1), + STORAGE_S3_ACCESS_KEY_ID: z.string().min(1), + STORAGE_S3_SECRET_ACCESS_KEY: z.string().min(1), + STORAGE_S3_BUCKET_ATTACHMENTS: z.string().min(1), + STORAGE_S3_BUCKET_AVATARS: z.string().min(1), + DB_REDIS_CONNECTION_STRING: z.string().min(1), + PORT: z.coerce.number().int().min(1).max(65535).default(3200), + NODE_ENV: z.enum(['development', 'production']).default('development') + }, + runtimeEnv: process.env +}); diff --git a/apps/v1Migration/logger.ts b/apps/v1Migration/logger.ts new file mode 100644 index 00000000..3bd1089d --- /dev/null +++ b/apps/v1Migration/logger.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; + +class Logger { + private logStream: fs.WriteStream | null = null; + private originalConsoleLog: typeof console.log; + private originalConsoleInfo: typeof console.info; + private originalConsoleError: typeof console.error; + + constructor(private logFile: string | null = null) { + // disable no-console + /* eslint-disable no-console */ + this.originalConsoleLog = console.log; + this.originalConsoleInfo = console.info; + this.originalConsoleError = console.error; + } + + init(logFile: string | null = null) { + if (logFile) { + this.logFile = logFile; + } + if (this.logFile) { + this.logStream = fs.createWriteStream(this.logFile, { flags: 'a' }); + } + console.log = console.info = console.error = this.log.bind(this); + } + + log(message: string) { + const timestamp = new Date().toLocaleString(); + const logMessage = `[${timestamp}] ${message}`; + this.originalConsoleLog(logMessage); + if (this.logStream) { + this.logStream.write(logMessage + '\n'); + } + } + + restore() { + console.log = this.originalConsoleLog; + console.info = this.originalConsoleInfo; + console.error = this.originalConsoleError; + if (this.logStream) { + this.logStream.end(); + } + } +} + +export const logger = new Logger(); diff --git a/apps/v1Migration/migrate.ts b/apps/v1Migration/migrate.ts new file mode 100644 index 00000000..bfceb501 --- /dev/null +++ b/apps/v1Migration/migrate.ts @@ -0,0 +1,132 @@ +import { runOrgMigration } from './migrationJob'; +import { orgs } from '@u22n/database/schema'; +import { eq } from '@u22n/database/orm'; +import { db } from '@u22n/database'; +import { logger } from './logger'; +import readline from 'readline'; +import crypto from 'crypto'; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +async function cliMigrationScript() { + console.info('Welcome to the Organization Migration CLI'); + + // Get username from environment variable + const currentUser = process.env.DB_PLANETSCALE_USERNAME; + if (!currentUser) { + console.info( + 'Error: DB_PLANETSCALE_USERNAME environment variable is not set.' + ); + rl.close(); + return; + } + + const isCorrectUser = await new Promise((resolve) => { + rl.question(`Is ${currentUser} the correct username? (Y/n): `, (answer) => { + resolve(answer.toLowerCase() !== 'n'); + }); + }); + + if (!isCorrectUser) { + console.info( + 'Please set the correct DB_PLANETSCALE_USERNAME environment variable.' + ); + rl.close(); + return; + } + + // Get organizations that haven't been migrated yet + // We'll assume an organization is not migrated if it doesn't have a personal space for its owner + const unmigratedOrgs = await db.query.orgs.findMany({ + /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call */ + where: (orgs) => eq(orgs.migratedToSpaces, false), + columns: { + id: true, + name: true + }, + with: { + owner: { + columns: { + id: true + } + } + } + }); + + if (unmigratedOrgs.length === 0) { + console.info('No unmigrated organizations found.'); + rl.close(); + return; + } + + console.info('Unmigrated organizations:'); + unmigratedOrgs.forEach((org, index) => { + console.info(`${index + 1}. ${org.name} (ID: ${org.id})`); + }); + + // Add batch size prompt + const batchSize = await new Promise((resolve) => { + rl.question( + 'Enter the batch size for processing (default 10): ', + (answer) => { + const size = parseInt(answer, 10); + resolve(isNaN(size) ? 10 : size); + } + ); + }); + + const logFile = './logs.txt'; + const useLogFile = await new Promise((resolve) => { + rl.question(`Do you want to log to ${logFile}? (Y/n): `, (answer) => { + resolve(answer.toLowerCase() !== 'n'); + }); + }); + + if (useLogFile) { + logger.init(logFile); + } else { + logger.init(); + } + + // Process organizations in batches + for (let i = 0; i < unmigratedOrgs.length; i += batchSize) { + const batchDistinctId = crypto.randomBytes(4).toString('hex'); + const batch = unmigratedOrgs.slice(i, i + batchSize); + const migrationPromises = batch.map(async (org) => { + logger.log( + `[Batch ${batchDistinctId}] Starting migration for organization ${org.name} (ID: ${org.id})` + ); + try { + await runOrgMigration({ orgId: org.id, batchDistinctId }); + // Mark the organization as migrated + await db + .update(orgs) + .set({ migratedToSpaces: true }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call + .where(eq(orgs.id, org.id)); + logger.log( + `[Batch ${batchDistinctId}] Successfully migrated organization ${org.name} (ID: ${org.id}) and marked as migrated` + ); + } catch (error) { + logger.log( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `[Batch ${batchDistinctId}] Error migrating organization ${org.name} (ID: ${org.id}): ${error}` + ); + } + }); + + await Promise.all(migrationPromises); + logger.log( + `[Batch ${batchDistinctId}] Completed batch ${Math.floor(i / batchSize) + 1} of ${Math.ceil(unmigratedOrgs.length / batchSize)}` + ); + } + + logger.log('Migration process completed.'); + logger.restore(); + rl.close(); +} + +cliMigrationScript().catch(console.error); diff --git a/apps/v1Migration/migrateOrg.ts b/apps/v1Migration/migrateOrg.ts new file mode 100644 index 00000000..1857705e --- /dev/null +++ b/apps/v1Migration/migrateOrg.ts @@ -0,0 +1,51 @@ +import { runOrgMigration } from './migrationJob'; +import { db } from '@u22n/database'; +import { logger } from './logger'; +import crypto from 'crypto'; + +async function migrateBranch(orgId: string) { + const logFile = './logs.txt'; + logger.init(logFile); + + const org = await db.query.orgs.findFirst({ + where: (orgs, { eq }) => eq(orgs.id, parseInt(orgId, 10)), + columns: { + id: true, + name: true + } + }); + + if (!org) { + console.error(`Organization with ID ${orgId} not found.`); + logger.restore(); + return; + } + + const batchDistinctId = crypto.randomBytes(4).toString('hex'); + + logger.log(`Starting migration for organization ${org.name} (ID: ${org.id})`); + + try { + await runOrgMigration({ orgId: org.id, batchDistinctId }); + logger.log( + `Successfully migrated organization ${org.name} (ID: ${org.id})` + ); + } catch (error) { + logger.log( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Error migrating organization ${org.name} (ID: ${org.id}): ${error}` + ); + } + + logger.log('Migration process completed.'); + logger.restore(); +} + +const orgId = process.argv[2]; + +if (!orgId) { + console.error('Please provide an organization ID as an argument.'); + process.exit(1); +} + +migrateBranch(orgId).catch(console.error); diff --git a/apps/v1Migration/migrationJob.ts b/apps/v1Migration/migrationJob.ts new file mode 100644 index 00000000..659bcf49 --- /dev/null +++ b/apps/v1Migration/migrationJob.ts @@ -0,0 +1,567 @@ +import { + convoParticipants, + convoToSpaces, + emailRoutingRulesDestinations, + orgMembers, + orgs, + spaceMembers, + spaces, + teams +} from '@u22n/database/schema'; +import { eq, type InferInsertModel } from '@u22n/database/orm'; +import { typeIdGenerator } from '@u22n/utils/typeid'; +import { db } from '@u22n/database'; +import { logger } from './logger'; + +export async function runOrgMigration({ + orgId, + batchDistinctId +}: { + orgId: number; + batchDistinctId: string; +}) { + logger.log( + `[Batch ${batchDistinctId}] --------------------------------------------------------------------------------` + ); + logger.log( + `[Batch ${batchDistinctId}] --------------------------------------------------------------------------------` + ); + logger.log( + `[Batch ${batchDistinctId}] --------------------------------------------------------------------------------` + ); + logger.log( + `[Batch ${batchDistinctId}] 🏃‍♂️ Running migration for org ${orgId}` + ); + const orgQueryResponse = await db.query.orgs.findFirst({ + where: eq(orgs.id, orgId), + columns: { + id: true, + ownerId: true + } + }); + if (!orgQueryResponse) { + logger.log(`[Batch ${batchDistinctId}] 🚨 org not found for id ${orgId}`); + return; + } + + const orgMemberIdArray: number[] = []; + const teamIdArray: number[] = []; + const consumedOrgSpaceShortcodes: string[] = []; + + //! Process Org Members + + // get list of org members: + const orgMembersQueryResponse = await db.query.orgMembers.findMany({ + where: eq(orgMembers.orgId, orgId), + columns: { + id: true, + accountId: true + } + }); + if (!orgMembersQueryResponse) { + logger.log( + `[Batch ${batchDistinctId}] 🚨 No org members found for org ${orgId}` + ); + return; + } + + // set the owner + const orgOwnerMembershipId = orgMembersQueryResponse.find( + (orgMember) => orgMember.accountId === orgQueryResponse.ownerId + )?.id; + if (!orgOwnerMembershipId) { + logger.log( + `[Batch ${batchDistinctId}] 🚨 No org owner found for org ${orgId}` + ); + return; + } + + // push all orgMemberIds to orgMemberIdArray + orgMembersQueryResponse.forEach((orgMember) => { + orgMemberIdArray.push(orgMember.id); + }); + + logger.log( + `[Batch ${batchDistinctId}] 🔍 Found ${orgMemberIdArray.length} org members` + ); + + // For each member in orgMemberIdArray: + for (const individualOrgMemberId of orgMemberIdArray) { + // get org member profile + const orgMemberProfileQueryResponse = await db.query.orgMembers.findFirst({ + where: eq(orgMembers.id, individualOrgMemberId), + columns: { + id: true + }, + with: { + profile: { + columns: { + id: true, + firstName: true, + lastName: true, + handle: true + } + } + } + }); + + // Create a Private Space with defaults + // check space name has not already been used + if (!orgMemberProfileQueryResponse?.profile) { + logger.log( + `[Batch ${batchDistinctId}] 🚨 orgMemberProfileQueryResponse not found for org ${orgId} member ${individualOrgMemberId}` + ); + break; + } + const userHandle = orgMemberProfileQueryResponse.profile.handle!; + + const preShortcode = `${userHandle.toLocaleLowerCase()}-personal`; + + const validatedShortcode = generateSpaceShortcode({ + input: preShortcode, + consumedShortcodes: consumedOrgSpaceShortcodes + }); + consumedOrgSpaceShortcodes.push(validatedShortcode); + + const newSpaceResponse = await db.insert(spaces).values({ + orgId: orgId, + publicId: typeIdGenerator('spaces'), + name: `${userHandle}'s Personal Space`, + type: 'private', + personalSpace: true, + color: 'cyan', + icon: 'house', + createdByOrgMemberId: orgOwnerMembershipId, + shortcode: validatedShortcode + }); + + // Add the org member as the space member + await db.insert(spaceMembers).values({ + orgId: orgId, + spaceId: Number(newSpaceResponse.insertId), + publicId: typeIdGenerator('spaceMembers'), + orgMemberId: Number(individualOrgMemberId), + addedByOrgMemberId: orgOwnerMembershipId, + role: 'admin', + canCreate: true, + canRead: true, + canComment: true, + canReply: true, + canDelete: true, + canChangeWorkflow: true, + canSetWorkflowToClosed: true, + canAddTags: true, + canMoveToAnotherSpace: true, + canAddToAnotherSpace: true, + canMergeConvos: true, + canAddParticipants: true + }); + + // set orgMember.personalSpaceId to spaceId + await db + .update(orgMembers) + .set({ + personalSpaceId: Number(newSpaceResponse.insertId) + }) + .where(eq(orgMembers.id, Number(individualOrgMemberId))); + + //! get all routingruleDestinations with orgMemberId match + const routingRuleDestinationsQueryResponse = + await db.query.emailRoutingRulesDestinations.findMany({ + where: eq( + emailRoutingRulesDestinations.orgMemberId, + individualOrgMemberId + ), + columns: { + id: true, + spaceId: true, + teamId: true, + ruleId: true, + orgMemberId: true + }, + with: { + rule: { + columns: { + id: true + }, + with: { + mailIdentities: { + columns: { + id: true + } + } + } + } + } + }); + + if (routingRuleDestinationsQueryResponse.length === 0) { + logger.log( + `[Batch ${batchDistinctId}] 🚨 No routing rule destinations found for org member ${individualOrgMemberId}` + ); + break; + } + + // set orgMember.defaultEmailIdentityId to first routingruleDestination.emailIdentityId + + const routingRuleEmailIdentityId = + routingRuleDestinationsQueryResponse[0]?.rule.mailIdentities[0]?.id; + + routingRuleEmailIdentityId && + (await db + .update(orgMembers) + .set({ + defaultEmailIdentityId: routingRuleEmailIdentityId + }) + .where(eq(orgMembers.id, Number(individualOrgMemberId)))); + logger.log( + `[Batch ${batchDistinctId}] 📧 updated org member ${individualOrgMemberId} default email identity` + ); + + // fetch all convos.id where user is participant.type === assigned | contributor + const allConvoIds: number[] = []; + let offset = 0; + const limit = 100; + let hasMore = true; + + logger.log( + `[Batch ${batchDistinctId}] 🔍 Fetching convos for orgMember ${individualOrgMemberId}` + ); + + while (hasMore) { + logger.log( + `[Batch ${batchDistinctId}] 📊 Fetching chunk: offset=${offset}, limit=${limit}` + ); + const responseChunk = await db.query.convoParticipants.findMany({ + where: eq(convoParticipants.orgMemberId, individualOrgMemberId), + columns: { convoId: true }, + limit: limit, + offset: offset + }); + logger.log( + `[Batch ${batchDistinctId}] 📊 Chunk size: ${responseChunk.length}` + ); + + const newConvoIds = responseChunk.map((cp) => cp.convoId); + allConvoIds.push(...newConvoIds); + logger.log( + `[Batch ${batchDistinctId}] 📊 New convo IDs: ${newConvoIds.join(', ')}` + ); + logger.log( + `[Batch ${batchDistinctId}] 📊 Total convo IDs so far: ${allConvoIds.length}` + ); + + hasMore = responseChunk.length === limit; + offset += limit; + logger.log( + `[Batch ${batchDistinctId}] 📊 Has more: ${hasMore}, New offset: ${offset}` + ); + } + + logger.log( + `[Batch ${batchDistinctId}] 🔢 Total convos found: ${allConvoIds.length}` + ); + + // insert convos2Spacestable entry + const convosToSpacesInsertValuesArray: InferInsertModel< + typeof convoToSpaces + >[] = []; + logger.log( + `[Batch ${batchDistinctId}] 🏗️ Preparing convoToSpaces insert array` + ); + for (const convoId of allConvoIds) { + const spaceId = Number(newSpaceResponse.insertId); + convosToSpacesInsertValuesArray.push({ + orgId: orgId, + convoId: convoId, + spaceId: spaceId, + publicId: typeIdGenerator('convoToSpaces') + }); + } + logger.log( + `[Batch ${batchDistinctId}] 🏗️ Prepared ${convosToSpacesInsertValuesArray.length} entries for convoToSpaces insert` + ); + + // Add this check before inserting + if (convosToSpacesInsertValuesArray.length > 0) { + await db.insert(convoToSpaces).values(convosToSpacesInsertValuesArray); + logger.log( + `[Batch ${batchDistinctId}] 🔗 linked ${convosToSpacesInsertValuesArray.length} convos to spaces` + ); + } else { + logger.log( + `[Batch ${batchDistinctId}] ℹ️ No convos to link for this org member/team` + ); + } + + for (const routingRuleDestination of routingRuleDestinationsQueryResponse) { + await db + .update(emailRoutingRulesDestinations) + .set({ + spaceId: Number(newSpaceResponse.insertId), + orgMemberId: null + }) + .where(eq(emailRoutingRulesDestinations.id, routingRuleDestination.id)); + logger.log( + `[Batch ${batchDistinctId}] 🔺 updated orgMember ${Number( + individualOrgMemberId + )} routing rule destination ${routingRuleDestination.id}` + ); + } + } + logger.log( + `[Batch ${batchDistinctId}] ⏱️ Finished processing ${orgMemberIdArray.length} org member spaces` + ); + + //! Process Teams + + // get list of org teams: + const orgTeamsQueryResponse = await db.query.teams.findMany({ + where: eq(teams.orgId, orgId), + columns: { + id: true + } + }); + + logger.log( + `[Batch ${batchDistinctId}] 🔍 Found ${orgTeamsQueryResponse.length} org teams` + ); + if (orgTeamsQueryResponse.length > 0) { + // push all teamIds to teamIdArray + orgTeamsQueryResponse.forEach((orgTeam) => { + teamIdArray.push(orgTeam.id); + }); + + // for each team in teamIdArray + + for (const individualTeamId of teamIdArray) { + // get team + const teamQueryResponse = await db.query.teams.findFirst({ + where: eq(teams.id, individualTeamId), + columns: { + id: true, + name: true + }, + with: { + members: { + columns: { + orgMemberId: true + } + } + } + }); + + if (!teamQueryResponse) { + logger.log( + `[Batch ${batchDistinctId}] 🚨 teamQueryResponse not found for org ${orgId} team ${individualTeamId}` + ); + break; + } + + // create private space + const validatedShortcode = generateSpaceShortcode({ + input: teamQueryResponse.name, + consumedShortcodes: consumedOrgSpaceShortcodes + }); + consumedOrgSpaceShortcodes.push(validatedShortcode); + + const newSpaceResponse = await db.insert(spaces).values({ + orgId: orgId, + publicId: typeIdGenerator('spaces'), + name: `${teamQueryResponse.name}'s Space`, + type: 'private', + personalSpace: true, + color: 'cyan', + icon: 'house', + createdByOrgMemberId: orgOwnerMembershipId, + shortcode: validatedShortcode + }); + + // Add the each team member as the space member + if (teamQueryResponse.members && teamQueryResponse.members.length > 0) { + for (const teamMember of teamQueryResponse.members) { + await db.insert(spaceMembers).values({ + orgId: orgId, + spaceId: Number(newSpaceResponse.insertId), + publicId: typeIdGenerator('spaceMembers'), + orgMemberId: teamMember.orgMemberId, + addedByOrgMemberId: orgOwnerMembershipId, + role: 'admin', + canCreate: true, + canRead: true, + canComment: true, + canReply: true, + canDelete: true, + canChangeWorkflow: true, + canSetWorkflowToClosed: true, + canAddTags: true, + canMoveToAnotherSpace: true, + canAddToAnotherSpace: true, + canMergeConvos: true, + canAddParticipants: true + }); + } + } + + // set team.defaultSpaceId > spaceId + await db + .update(teams) + .set({ + defaultSpaceId: Number(newSpaceResponse.insertId) + }) + .where(eq(teams.id, Number(individualTeamId))); + + // get all routingruleDestinations with teamId match + const routingRuleDestinationsQueryResponse = + await db.query.emailRoutingRulesDestinations.findMany({ + where: eq( + emailRoutingRulesDestinations.teamId, + Number(individualTeamId) + ), + columns: { + id: true, + spaceId: true, + teamId: true, + ruleId: true, + orgMemberId: true + }, + with: { + rule: { + columns: { + id: true + }, + with: { + mailIdentities: { + columns: { + id: true + } + } + } + } + } + }); + + if (routingRuleDestinationsQueryResponse.length === 0) { + logger.log( + `[Batch ${batchDistinctId}] 🚨 No routing rule destinations found for team ${Number(individualTeamId)}` + ); + break; + } + + // set team.defaultEmailIdentityId to first routingruleDestination.emailIdentityId + + const routingRuleEmailIdentityId = + routingRuleDestinationsQueryResponse[0]?.rule.mailIdentities[0]?.id; + + routingRuleEmailIdentityId && + (await db + .update(teams) + .set({ + defaultEmailIdentityId: routingRuleEmailIdentityId + }) + .where(eq(teams.id, Number(Number(individualTeamId))))); + logger.log( + `[Batch ${batchDistinctId}] 📧 updated team ${Number(individualTeamId)} default email identity` + ); + + // fetch all convos.id where team is participant + // fetch all convos.id where user is participant.type === assigned | contributor + const allConvoIds: number[] = []; + let offset = 0; + const limit = 100; + let hasMore = true; + + while (hasMore) { + const responseChunk = await db.query.convoParticipants.findMany({ + where: eq(convoParticipants.teamId, Number(individualTeamId)), + columns: { convoId: true }, + limit: limit, + offset: offset + }); + allConvoIds.push(...responseChunk.map((cp) => cp.convoId)); + hasMore = responseChunk.length === limit; + offset += limit; + } + + // insert convos2Spacestable entry + const convosToSpacesInsertValuesArray: InferInsertModel< + typeof convoToSpaces + >[] = []; + for (const convoId of allConvoIds) { + const spaceId = Number(newSpaceResponse.insertId); + convosToSpacesInsertValuesArray.push({ + orgId: orgId, + convoId: convoId, + spaceId: spaceId, + publicId: typeIdGenerator('convoToSpaces') + }); + } + + // Add this check before inserting + if (convosToSpacesInsertValuesArray.length > 0) { + await db.insert(convoToSpaces).values(convosToSpacesInsertValuesArray); + logger.log( + `[Batch ${batchDistinctId}] 🔗 linked ${convosToSpacesInsertValuesArray.length} convos to spaces` + ); + } else { + logger.log( + `[Batch ${batchDistinctId}] ℹ️ No convos to link for this org member/team` + ); + } + + for (const routingRuleDestination of routingRuleDestinationsQueryResponse) { + await db + .update(emailRoutingRulesDestinations) + .set({ + spaceId: Number(newSpaceResponse.insertId), + teamId: null + }) + .where( + eq(emailRoutingRulesDestinations.id, routingRuleDestination.id) + ); + logger.log( + `[Batch ${batchDistinctId}] 🔺 updated team ${Number( + individualTeamId + )} routing rule destination ${routingRuleDestination.id}` + ); + } + } + logger.log( + `[Batch ${batchDistinctId}] 📦 created ${teamIdArray.length} team spaces` + ); + } + + logger.log( + `[Batch ${batchDistinctId}] 🏁 Finished processing orgId migration` + ); +} + +function generateSpaceShortcode({ + input, + consumedShortcodes +}: { + input: string; + consumedShortcodes: string[]; +}) { + const cleanedInput = input.toLocaleLowerCase().replace(/[^a-z0-9]/g, ''); + if (!consumedShortcodes.includes(cleanedInput)) { + return cleanedInput; + } + const existingMatchingSpaces = consumedShortcodes.filter((spaceShortcode) => + spaceShortcode.startsWith(cleanedInput) + ); + let currentSuffix = existingMatchingSpaces.length; + let retries = 0; + let validatedShortcode = `${cleanedInput}${currentSuffix}`; + while (retries < 30) { + if (consumedShortcodes.includes(validatedShortcode)) { + retries++; + currentSuffix++; + validatedShortcode = `${cleanedInput}${currentSuffix}`; + continue; + } + break; + } + + return validatedShortcode; +} diff --git a/apps/v1Migration/package.json b/apps/v1Migration/package.json new file mode 100644 index 00000000..4fc3adf1 --- /dev/null +++ b/apps/v1Migration/package.json @@ -0,0 +1,25 @@ +{ + "name": "@u22n/v1migration", + "private": true, + "type": "module", + "version": "0.9.0", + "scripts": { + "data:migration:branch": "dotenv -e ../../.env.test.local -- tsx migrate.ts", + "data:migrate:organization": "dotenv -e ../../.env.test.local -- tsx migrateOrg.ts", + "check": "tsc --noEmit", + "check:org-convos": "dotenv -e ../../.env.test.local -- tsx checkOrgConvos.ts" + }, + "dependencies": { + "@t3-oss/env-core": "^0.11.0", + "@u22n/database": "workspace:*", + "@u22n/tsconfig": "^0.0.2", + "@u22n/utils": "workspace:*", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "tsup": "^8.1.2", + "tsx": "^4.16.2", + "typescript": "5.5.3" + } +} diff --git a/apps/v1Migration/tsconfig.json b/apps/v1Migration/tsconfig.json new file mode 100644 index 00000000..dd49d1d4 --- /dev/null +++ b/apps/v1Migration/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@u22n/tsconfig" +} diff --git a/apps/web/package.json b/apps/web/package.json index 0c5c1ab4..0df38e21 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,10 @@ }, "dependencies": { "@calcom/embed-react": "^1.5.0", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.9.0", "@marsidev/react-turnstile": "^0.7.2", "@phosphor-icons/react": "^2.1.7", @@ -54,6 +58,7 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "framer-motion": "^11.3.24", + "immer": "^10.1.1", "input-otp": "^1.2.4", "jotai": "^2.9.0", "next": "14.2.5", @@ -75,6 +80,7 @@ "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "tunnel-rat": "^0.1.2", + "use-debounce": "^10.0.1", "vaul": "^0.9.1", "zod": "^3.23.8", "zustand": "^4.5.4" diff --git a/apps/web/src/app/(login)/page.tsx b/apps/web/src/app/(login)/page.tsx index 4f7dac39..422333aa 100644 --- a/apps/web/src/app/(login)/page.tsx +++ b/apps/web/src/app/(login)/page.tsx @@ -107,7 +107,7 @@ export default function Page() { toast.success('Sign in successful!', { description: 'Redirecting you to your conversations' }); - router.replace(`/${defaultOrg}/convo`); + router.replace(`/${defaultOrg}/personal/convo`); } } }); @@ -138,7 +138,7 @@ export default function Page() { if (!defaultOrgShortcode) { router.replace('/join/org'); } else { - router.replace(`/${defaultOrgShortcode}/convo`); + router.replace(`/${defaultOrgShortcode}/personal/convo`); } } else { setTwoFactorDialogOpen(true); diff --git a/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/[convoId]/page.tsx b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/[convoId]/page.tsx new file mode 100644 index 00000000..6871544c --- /dev/null +++ b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/[convoId]/page.tsx @@ -0,0 +1,3 @@ +'use client'; +import Page from '../../../convo/[convoId]/page'; +export default Page; diff --git a/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/layout.tsx b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/layout.tsx new file mode 100644 index 00000000..226c49c0 --- /dev/null +++ b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/layout.tsx @@ -0,0 +1,3 @@ +'use client'; +import Layout from '../../convo/layout'; +export default Layout; diff --git a/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/new/page.tsx b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/new/page.tsx new file mode 100644 index 00000000..bcb36424 --- /dev/null +++ b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/new/page.tsx @@ -0,0 +1,3 @@ +'use client'; +import Page from '../../../convo/new/page'; +export default Page; diff --git a/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/page.tsx b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/page.tsx new file mode 100644 index 00000000..919fbb5d --- /dev/null +++ b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/page.tsx @@ -0,0 +1,3 @@ +'use client'; +import Page from '../../convo/page'; +export default Page; diff --git a/apps/web/src/app/[orgShortcode]/[spaceShortcode]/route.ts b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/route.ts new file mode 100644 index 00000000..c9cde543 --- /dev/null +++ b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/route.ts @@ -0,0 +1,9 @@ +import { type NextRequest } from 'next/server'; +import { redirect } from 'next/navigation'; + +export function GET( + _: NextRequest, + { params }: { params: { orgShortcode: string; spaceShortcode: string } } +) { + redirect(`/${params.orgShortcode}/${params.spaceShortcode}/convo`); +} diff --git a/apps/web/src/app/[orgShortcode]/[spaceShortcode]/settings/_components/settingsTitle.tsx b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/settings/_components/settingsTitle.tsx new file mode 100644 index 00000000..1336a7e9 --- /dev/null +++ b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/settings/_components/settingsTitle.tsx @@ -0,0 +1,3 @@ +export function SettingsTitle({ title }: { title: string }) { + return {title}; +} diff --git a/apps/web/src/app/[orgShortcode]/[spaceShortcode]/settings/page.tsx b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/settings/page.tsx new file mode 100644 index 00000000..59e6cdad --- /dev/null +++ b/apps/web/src/app/[orgShortcode]/[spaceShortcode]/settings/page.tsx @@ -0,0 +1,1750 @@ +'use client'; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, + SelectLabel, + SelectSeparator +} from '@/src/components/shadcn-ui/select'; +import { + ArrowLeft, + Check, + Circle, + DotsThree, + Globe, + Pencil, + Plus, + SpinnerGap, + SquaresFour, + TagChevron, + UsersThree +} from '@phosphor-icons/react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/src/components/shadcn-ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/src/components/shadcn-ui/dropdown-menu'; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage +} from '@/src/components/shadcn-ui/form'; +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/src/components/shadcn-ui/popover'; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from '@/src/components/shadcn-ui/tooltip'; +import { useOrgShortcode, useSpaceShortcode } from '@/src/hooks/use-params'; +import { type SpaceWorkflowType, type SpaceType } from '@u22n/utils/spaces'; +import { typeIdValidator, type TypeId } from '@u22n/utils/typeid'; +import { SettingsTitle } from './_components/settingsTitle'; +import { type UiColor, uiColors } from '@u22n/utils/colors'; +import { Button } from '@/src/components/shadcn-ui/button'; +import { Switch } from '@/src/components/shadcn-ui/switch'; +import { CopyButton } from '@/src/components/copy-button'; +import { Input } from '@/src/components/shadcn-ui/input'; +import { Badge } from '@/src/components/shadcn-ui/badge'; +import { PopoverAnchor } from '@radix-ui/react-popover'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useEffect, useMemo, useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import { useForm } from 'react-hook-form'; +import { platform } from '@/src/lib/trpc'; +import { cn } from '@/src/lib/utils'; +import Link from 'next/link'; +import { z } from 'zod'; + +export default function SettingsPage() { + const orgShortcode = useOrgShortcode(); + const spaceShortcode = useSpaceShortcode(); + + const [showSaved, setShowSaved] = useState(false); + + const { data: spaceSettings, isLoading } = + platform.spaces.settings.getSpacesSettings.useQuery({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode + }); + + const isSpaceAdmin = useMemo(() => { + return spaceSettings?.role === 'admin'; + }, [spaceSettings?.role]); + + useEffect(() => { + if (!showSaved) return; + const timeout = setTimeout(() => setShowSaved(false), 2500); + return () => clearTimeout(timeout); + }, [showSaved]); + + return ( +
+
+ {isLoading ? ( +
Loading...
+ ) : !spaceSettings?.settings ? ( +
Space Not Found
+ ) : ( +
+
+
+
+
+ +
+ +
+ + + +
+
+
+ {showSaved && ( +
+ Saved + +
+ )} +
+ {!isSpaceAdmin && ( +
+ + Only admins of this space can edit settings + +
+ )} +
+
+
+ + + {spaceSettings?.settings?.publicId} + + +
+
+
+ + + + {/* */} +
+ )} +
+
+ ); +} + +function NameField({ + orgShortcode, + spaceShortcode, + initialValue, + showSaved, + isSpaceAdmin +}: { + orgShortcode: string; + spaceShortcode: string; + initialValue: string; + isSpaceAdmin: boolean; + showSaved: (value: boolean) => void; +}) { + const { mutateAsync: setSpaceName, isSuccess: setSpaceNameSuccess } = + platform.spaces.settings.setSpaceName.useMutation(); + const [editName, setEditName] = useState(initialValue); + const [showEditNameField, setShowEditNameField] = useState(false); + const orgMemberSpacesQueryCache = + platform.useUtils().spaces.getOrgMemberSpaces; + + const debouncedInput = useDebouncedCallback( + async (value) => { + if (value === initialValue && !setSpaceNameSuccess) return; + const parsed = z + .string() + .min(1) + .max(64) + .safeParse(value as string); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? null, + success: false + }; + } + await setSpaceName({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode, + spaceName: editName + }); + showSaved(true); + await orgMemberSpacesQueryCache.invalidate(); + }, + // delay in ms + 1000 + ); + + useEffect(() => { + if (typeof editName === 'undefined') return; + void debouncedInput(editName); + }, [editName, debouncedInput]); + + return ( + <> + {showEditNameField ? ( +
+ setEditName(e.target.value)} + /> +
+ ) : ( +
+ {initialValue} + +
+ )} + + ); +} + +function DescriptionField({ + orgShortcode, + spaceShortcode, + initialValue, + showSaved, + isSpaceAdmin +}: { + orgShortcode: string; + spaceShortcode: string; + initialValue: string; + isSpaceAdmin: boolean; + showSaved: (value: boolean) => void; +}) { + const { + mutateAsync: setSpaceDescription, + isSuccess: setSpaceDescriptionSuccess + } = platform.spaces.settings.setSpaceDescription.useMutation(); + const [editDescription, setEditDescription] = useState(initialValue); + const [showEditDescriptionField, setShowEditDescriptionField] = + useState(false); + const orgMemberSpacesQueryCache = + platform.useUtils().spaces.getOrgMemberSpaces; + + const debouncedInput = useDebouncedCallback( + async (value) => { + if (value === initialValue && !setSpaceDescriptionSuccess) return; + const parsed = z + .string() + .min(1) + .max(64) + .safeParse(value as string); + if (!parsed.success) { + return { + error: parsed.error.issues[0]?.message ?? null, + success: false + }; + } + await setSpaceDescription({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode, + spaceDescription: editDescription + }); + showSaved(true); + await orgMemberSpacesQueryCache.invalidate(); + }, + // delay in ms + 1000 + ); + + useEffect(() => { + if (typeof editDescription === 'undefined') return; + void debouncedInput(editDescription); + }, [editDescription, debouncedInput]); + + return ( + <> + {showEditDescriptionField ? ( +
+ setEditDescription(e.target.value)} + /> +
+ ) : ( +
+ {initialValue === '' ? ( + Description + ) : ( + {initialValue} + )} + +
+ )} + + ); +} + +function ColorField({ + orgShortcode, + spaceShortcode, + initialValue, + showSaved, + isSpaceAdmin +}: { + orgShortcode: string; + spaceShortcode: string; + initialValue: string; + isSpaceAdmin: boolean; + showSaved: (value: boolean) => void; +}) { + const { mutateAsync: setSpaceColor } = + platform.spaces.settings.setSpaceColor.useMutation(); + const [activeColor, setActiveColor] = useState(initialValue); + + const orgMemberSpacesQueryCache = + platform.useUtils().spaces.getOrgMemberSpaces; + + async function handleSpaceColor(value: UiColor) { + await setSpaceColor({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode, + spaceColor: value + }); + setActiveColor(value); + showSaved(true); + await orgMemberSpacesQueryCache.invalidate(); + } + + return ( +
+ +
+ {uiColors.map((color) => ( +
{ + isSpaceAdmin && (await handleSpaceColor(color)); + }}> + {activeColor === color ? ( + + ) : ( + + )} +
+ ))} +
+
+ ); +} + +function VisibilityField({ + orgShortcode, + spaceShortcode, + initialValue, + showSaved, + isSpaceAdmin +}: { + orgShortcode: string; + spaceShortcode: string; + initialValue: string; + isSpaceAdmin: boolean; + showSaved: (value: boolean) => void; +}) { + const { data: canAddSpace } = platform.org.iCanHaz.space.useQuery( + { + orgShortcode: orgShortcode + }, + { + staleTime: 1000 + } + ); + + const { mutateAsync: setSpaceType } = + platform.spaces.settings.setSpaceType.useMutation(); + const [activeType, setActiveType] = useState(initialValue); + + const orgMemberSpacesQueryCache = + platform.useUtils().spaces.getOrgMemberSpaces; + + async function handleSpaceType(value: SpaceType) { + await setSpaceType({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode, + spaceType: value + }); + setActiveType(value); + showSaved(true); + await orgMemberSpacesQueryCache.invalidate(); + } + + return ( + <> + + + ); +} + +function Workflows({ + orgShortcode, + spaceShortcode, + showSaved, + isSpaceAdmin +}: { + orgShortcode: string; + spaceShortcode: string; + isSpaceAdmin: boolean; + showSaved: (value: boolean) => void; +}) { + const { data: spaceWorkflows, isLoading: workflowsLoading } = + platform.spaces.workflows.getSpacesWorkflows.useQuery({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode + }); + + const spaceWorkflowUtils = + platform.useUtils().spaces.workflows.getSpacesWorkflows; + + const { data: canIHazWorkflows } = + platform.org.iCanHaz.spaceWorkflow.useQuery({ + orgShortcode: orgShortcode + }); + + const { mutateAsync: enableWorkflows, isPending: enablingWorkflows } = + platform.spaces.workflows.enableSpacesWorkflows.useMutation({ + onSuccess: () => { + void spaceWorkflowUtils.invalidate(); + } + }); + + const hasWorkflowsConfigured = useMemo(() => { + return ( + !!spaceWorkflows?.open?.length || + !!spaceWorkflows?.active?.length || + !!spaceWorkflows?.closed?.length + ); + }, [spaceWorkflows]); + + const canAddOpenWorkflow = useMemo(() => { + if (!canIHazWorkflows) return false; + return (canIHazWorkflows.open ?? 0) > (spaceWorkflows?.open?.length ?? 0); + }, [canIHazWorkflows, spaceWorkflows]); + + const [showNewOpenWorkflow, setShowNewOpenWorkflow] = useState(false); + useEffect(() => { + if (showNewOpenWorkflow) return; + showSaved(true); + setShowNewOpenWorkflow(false); + }, [showNewOpenWorkflow, showSaved]); + + const canAddActiveWorkflow = useMemo(() => { + if (!canIHazWorkflows) return false; + return ( + (canIHazWorkflows.active ?? 0) > (spaceWorkflows?.active?.length ?? 0) + ); + }, [canIHazWorkflows, spaceWorkflows]); + + const [showNewActiveWorkflow, setShowNewActiveWorkflow] = useState(false); + useEffect(() => { + if (showNewActiveWorkflow) return; + showSaved(true); + setShowNewActiveWorkflow(false); + }, [showNewActiveWorkflow, showSaved]); + + const canAddClosedWorkflow = useMemo(() => { + if (!canIHazWorkflows) return false; + return ( + (canIHazWorkflows.closed ?? 0) > (spaceWorkflows?.closed?.length ?? 0) + ); + }, [canIHazWorkflows, spaceWorkflows]); + + const [showNewClosedWorkflow, setShowNewClosedWorkflow] = useState(false); + useEffect(() => { + if (showNewClosedWorkflow) return; + showSaved(true); + setShowNewClosedWorkflow(false); + }, [showNewClosedWorkflow, showSaved]); + + const [subShowSaved, setSubShowSaved] = useState(false); + useEffect(() => { + if (!subShowSaved) return; + showSaved(true); + setTimeout(() => { + setSubShowSaved(false); + }, 2500); + }, [subShowSaved, showSaved]); + + return ( +
+ + {workflowsLoading ? ( +
+ Loading Workflows... +
+ ) : !hasWorkflowsConfigured ? ( +
+ Enable Workflows + {enablingWorkflows ? ( + + ) : ( + + enableWorkflows({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode + }) + } + /> + )} +
+ ) : ( +
+
+
+ Open +
+
+ {!spaceWorkflows?.open?.length ? ( + No Workflows + ) : ( + spaceWorkflows?.open?.map((workflow) => ( + + )) + )} + {showNewOpenWorkflow && ( + + )} + {isSpaceAdmin && + (canAddOpenWorkflow ? ( +
+ +
+ ) : ( + + +
+ + Pro plan + +
+ +
+
+
+ + Upgrade to Pro plan + to add more Workflows + +
+ ))} +
+
+
+
+ Active +
+
+ {!spaceWorkflows?.active?.length ? ( + No Workflows + ) : ( + spaceWorkflows?.active?.map((workflow) => ( + + )) + )} + {showNewActiveWorkflow && ( + + )} + {isSpaceAdmin && + (canAddActiveWorkflow ? ( +
+ +
+ ) : ( + + +
+ + Pro plan + +
+ +
+
+
+ + Upgrade to Pro plan + to add more Workflows + +
+ ))} +
+
+
+
+ Closed +
+
+ {!spaceWorkflows?.closed?.length ? ( + No Workflows + ) : ( + spaceWorkflows?.closed?.map((workflow) => ( + + )) + )} + {showNewClosedWorkflow && ( + + )} + {isSpaceAdmin && + (canAddClosedWorkflow ? ( +
+ +
+ ) : ( + + +
+ + Pro plan + +
+ +
+
+
+ + Upgrade to Pro plan + to add more Workflows + +
+ ))} +
+
+
+ )} +
+ ); +} + +function WorkflowItem({ + orgShortcode, + spaceShortcode, + workflow, + isAdmin +}: { + orgShortcode: string; + spaceShortcode: string; + // TODO: make this type based on the return of the query + workflow: { + name: string; + color: UiColor; + description: string | null; + publicId: TypeId<'spaceWorkflows'>; + icon: string; + disabled: boolean; + type: SpaceWorkflowType; + order: number; + }; + isAdmin: boolean; +}) { + const [editWorkflow, setEditWorkflow] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + + const { mutateAsync: editSpaceWorkflow, isPending } = + platform.spaces.workflows.editSpaceWorkflow.useMutation(); + const { mutateAsync: disableSpaceWorkflow } = + platform.spaces.workflows.disableSpaceWorkflow.useMutation(); + + const orgMemberSpacesQueryCache = + platform.useUtils().spaces.getOrgMemberSpaces; + const spaceWorkflowQueryCache = + platform.useUtils().spaces.workflows.getSpacesWorkflows; + + const editSpaceWorkflowFormSchema = z.object({ + name: z.string().min(1).max(32), + description: z.string().min(0).max(128).optional(), + color: z.enum(uiColors) + }); + + const form = useForm>({ + resolver: zodResolver(editSpaceWorkflowFormSchema), + defaultValues: { + name: workflow.name, + description: workflow.description ?? '', + color: workflow.color + } + }); + + const handleSubmit = async ( + values: z.infer + ) => { + await editSpaceWorkflow({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode, + name: values.name, + description: values.description, + color: values.color, + spaceWorkflowPublicId: workflow.publicId + }); + + true; + setEditWorkflow(false); + await orgMemberSpacesQueryCache.invalidate(); + await spaceWorkflowQueryCache.invalidate(); + form.reset(); + }; + + const handleDisableWorkflow = async () => { + await disableSpaceWorkflow({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode, + spaceWorkflowPublicId: workflow.publicId, + disable: !workflow.disabled + }); + + true; + setEditWorkflow(false); + await orgMemberSpacesQueryCache.invalidate(); + await spaceWorkflowQueryCache.invalidate(); + form.reset(); + }; + return ( + <> + {!editWorkflow ? ( +
+
+
+ +
+
+
+ {workflow.name} +
+
+ {workflow.description ?? 'No description'} +
+
+
+
+ {workflow.disabled && ( + + Disabled + + )} + {isAdmin && ( + + + + + + { + setEditWorkflow(true); + }}> + Edit Workflow + + { + void handleDisableWorkflow(); + }}> + {workflow.disabled ? 'Enable Workflow' : 'Disable Workflow'} + + { + void setDeleteModalOpen(true); + }}> + Delete Workflow + + + + )} + {deleteModalOpen && ( + + )} +
+
+ ) : ( +
+
+ ( + + + + +
+ +
+
+ +
+ {uiColors.map((color) => ( +
field.onChange(color)}> + {field.value === color ? ( + + ) : ( + + )} +
+ ))} +
+
+
+
+ +
+ )} + /> + + ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> + + + +
+
+ )} + + ); +} + +function NewSpaceWorkflow({ + orgShortcode, + spaceShortcode, + showNewWorkflowComponent, + type, + order +}: { + orgShortcode: string; + spaceShortcode: string; + type: SpaceWorkflowType; + order: number; + showNewWorkflowComponent: (value: boolean) => void; +}) { + const { mutateAsync: addNewSpaceWorkflow, isPending } = + platform.spaces.workflows.addNewSpaceWorkflow.useMutation(); + + const orgMemberSpacesQueryCache = + platform.useUtils().spaces.getOrgMemberSpaces; + const spaceWorkflowQueryCache = + platform.useUtils().spaces.workflows.getSpacesWorkflows; + + const newSpaceWorkflowFormSchema = z.object({ + name: z.string().min(1).max(32), + description: z.string().min(0).max(128).optional(), + color: z.enum(uiColors) + }); + + const form = useForm>({ + resolver: zodResolver(newSpaceWorkflowFormSchema), + defaultValues: { + name: '', + description: '', + color: uiColors[Math.floor(Math.random() * uiColors.length)] + } + }); + + const handleSubmit = async ( + values: z.infer + ) => { + await addNewSpaceWorkflow({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode, + type: type, + name: values.name, + description: values.description, + color: values.color, + order: order + }); + + showNewWorkflowComponent(false); + await orgMemberSpacesQueryCache.invalidate(); + await spaceWorkflowQueryCache.invalidate(); + form.reset(); + }; + + return ( + <> +
+
+ ( + + + + +
+ +
+
+ +
+ {uiColors.map((color) => ( +
field.onChange(color)}> + {field.value === color ? ( + + ) : ( + + )} +
+ ))} +
+
+
+
+ +
+ )} + /> + + ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> + + + +
+
+ + ); +} + +function DeleteWorkflowModal({ + orgShortcode, + spaceShortcode, + workflowToDelete +}: { + orgShortcode: string; + spaceShortcode: string; + workflowToDelete: { + name: string; + color: UiColor; + description: string | null; + publicId: TypeId<'spaceWorkflows'>; + icon: string; + disabled: boolean; + type: SpaceWorkflowType; + order: number; + }; +}) { + const { data: spaceWorkflows } = + platform.spaces.workflows.getSpacesWorkflows.useQuery({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode + }); + + const { mutateAsync: deleteSpaceWorkflow, isPending: isSubmitting } = + platform.spaces.workflows.deleteSpaceWorkflow.useMutation(); + + const orgMemberSpacesQueryCache = + platform.useUtils().spaces.getOrgMemberSpaces; + const spaceWorkflowQueryCache = + platform.useUtils().spaces.workflows.getSpacesWorkflows; + + const deleteSpaceWorkflowSchema = z.object({ + replacementSpaceWorkflowPublicId: typeIdValidator('spaceWorkflows') + }); + + const form = useForm>({ + resolver: zodResolver(deleteSpaceWorkflowSchema), + defaultValues: { + replacementSpaceWorkflowPublicId: + spaceWorkflows?.open.filter( + (Workflow) => Workflow.publicId !== workflowToDelete.publicId + )[0]?.publicId ?? + spaceWorkflows?.active.filter( + (Workflow) => Workflow.publicId !== workflowToDelete.publicId + )[0]?.publicId ?? + spaceWorkflows?.closed.filter( + (Workflow) => Workflow.publicId !== workflowToDelete.publicId + )[0]?.publicId + } + }); + + const handleSubmit = async ( + values: z.infer + ) => { + await deleteSpaceWorkflow({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode, + spaceWorkflowPublicId: workflowToDelete.publicId, + replacementSpaceWorkflowPublicId: values.replacementSpaceWorkflowPublicId + }); + await orgMemberSpacesQueryCache.invalidate(); + await spaceWorkflowQueryCache.invalidate(); + form.reset(); + }; + + return ( + + + + Delete {workflowToDelete.name} Workflow? + + + Select a replacement for Conversations using this Workflow. + + + +
+
+
+ ( + + + + + )} + /> +
+ +
+
+ + + + +
+
+
+
+
+
+ ); +} + +function Tags({ + orgShortcode, + spaceShortcode +}: { + orgShortcode: string; + spaceShortcode: string; + isSpaceAdmin: boolean; +}) { + const { data: spaceTags } = platform.spaces.tags.getSpacesTags.useQuery({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode + }); + + const spaceTagsQueryCache = platform.useUtils().spaces.tags.getSpacesTags; + + const { mutateAsync: addNewSpaceTag } = + platform.spaces.tags.addNewSpaceTag.useMutation(); + + const addNewTag = async () => { + const randomColor = + uiColors[Math.floor(Math.random() * uiColors.length)] ?? 'cyan'; + await addNewSpaceTag({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode, + label: 'New Tag', + color: randomColor, + description: 'New Tag Description' + }); + await spaceTagsQueryCache.invalidate(); + }; + + return ( +
+ +
+ {!spaceTags?.length ? ( +
+ No tags + +
+ ) : ( + spaceTags?.map((tag) => ( +
+ +
+ )) + )} +
+
+ ); +} + +function Tag({ + // orgShortcode, + // spaceShortcode, + // tagPublicId, + label, + description, + // icon, + color, + // convoPublicId, + // actions = true, + editNameActive = false +}: { + orgShortcode: string; + spaceShortcode: string; + tagPublicId: TypeId<'spaceTags'>; + label: string; + description: string | null; + icon: string; + color: UiColor; + convoPublicId?: TypeId<'convos'> | null; + actions?: boolean; + editNameActive?: boolean; +}) { + const [editNameMode, setEditNameMode] = useState(editNameActive); + const [editDescriptionMode, setEditDescriptionMode] = + useState(false); + const [editColorMode, setEditColorMode] = useState(false); + + const TagItemBlock = ( + <> +
+ + {label} +
+ + + + + + edit name + + + + edit description + + + + edit color + + + ); + + return ( + <> + {!description ? ( + { TagItemBlock } + ) : ( + + {TagItemBlock} + + {description} + + + )} + + ); +} diff --git a/apps/web/src/app/[orgShortcode]/_components/bottom-nav.tsx b/apps/web/src/app/[orgShortcode]/_components/bottom-nav.tsx index 5d7ec95f..cd07a1bd 100644 --- a/apps/web/src/app/[orgShortcode]/_components/bottom-nav.tsx +++ b/apps/web/src/app/[orgShortcode]/_components/bottom-nav.tsx @@ -49,7 +49,7 @@ function NewConvoButton() { aria-current={isActive} className="hover:bg-accent-2 hover:text-base-9 text-base-9 [&[aria-current=true]]:text-base-12 group flex h-20 w-24 flex-col items-center justify-center gap-2 px-1 py-1" asChild> - +
- +
{ + void invalidateSpaces.invalidate(); + setOpen(false); + void router.push(`/${orgShortcode}/${data.spaceShortcode}/settings`); + } + }); + + const form = useForm>({ + resolver: zodResolver(newSpaceFormSchema), + defaultValues: { + spaceName: '', + description: '', + color: uiColors[Math.floor(Math.random() * uiColors.length)], + type: 'open' as SpaceType + } + }); + + const handleSubmit = async (values: z.infer) => { + await createNewSpace({ + orgShortcode: orgShortcode, + spaceName: values.spaceName, + spaceDescription: values.description, + spaceColor: values.color ?? 'cyan', + spaceType: values.type + }).catch(() => null); + form.reset(); + }; + + const [open, setOpen] = useState(false); + + return ( + { + if (isSubmitting) return; + setOpen(!open); + }}> + +
+ +
+
+ + + + Create a new Space + + Spaces are where a team, department, or group, can work with their + own Conversations, Workflows and Tags. + + + +
+
+ ( + + + + +
+ +
+
+ +
+ {uiColors.map((color) => ( +
field.onChange(color)}> + {field.value === color ? ( + + ) : ( + + )} +
+ ))} +
+
+
+
+ +
+ )} + /> + + ( + + + + + + + )} + /> +
+ + ( + + + + + + + )} + /> + + ( + + + + )} + /> + + + + +
+
+ ); +} diff --git a/apps/web/src/app/[orgShortcode]/_components/sidebar-content.tsx b/apps/web/src/app/[orgShortcode]/_components/sidebar-content.tsx index 80f156c8..fa857bfe 100644 --- a/apps/web/src/app/[orgShortcode]/_components/sidebar-content.tsx +++ b/apps/web/src/app/[orgShortcode]/_components/sidebar-content.tsx @@ -29,8 +29,9 @@ import { Palette, Monitor, Question, - User, - SpinnerGap + SpinnerGap, + SquaresFour, + DotsThree } from '@phosphor-icons/react'; import { Tooltip, @@ -42,12 +43,16 @@ import { ToggleGroupItem } from '@/src/components/shadcn-ui/toggle-group'; import { useOrgScopedRouter, useOrgShortcode } from '@/src/hooks/use-params'; +import { type InferQueryLikeData } from '@trpc/react-query/shared'; +import { Separator } from '@/src/components/shadcn-ui/separator'; +import { Button } from '@/src/components/shadcn-ui/button'; +import { logoutCleanup, platform } from '@/src/lib/trpc'; import { useIsMobile } from '@/src/hooks/use-is-mobile'; import { useMutation } from '@tanstack/react-query'; +import { NewSpaceModal } from './new-space-modal'; import { Avatar } from '@/src/components/avatar'; import { sidebarSubmenuOpenAtom } from './atoms'; import { useRouter } from 'next/navigation'; -import { platform } from '@/src/lib/trpc'; import { useTheme } from 'next-themes'; import { cn } from '@/src/lib/utils'; import { ms } from '@u22n/utils/ms'; @@ -58,6 +63,7 @@ import Link from 'next/link'; export function SidebarContent() { const isMobile = useIsMobile(); + return (
['spaces'][number]; + +function SpaceItem({ + space: spaceData, + isPersonal +}: { + space: SingleSpaceResponse; + isPersonal: boolean; +}) { + const { scopedUrl } = useOrgScopedRouter(); + const router = useRouter(); + const orgShortCode = useOrgShortcode(); + + const SpaceIcon = () => { + return ( + + ); + }; + + return ( +
+ +
+ +
+ + {isPersonal ? 'My Personal Space' : spaceData.name || 'Unnamed Space'} + + +
+ + + + + + {/* TODO: Add in with the notifications + + Notifications + + + + + Top + + + Bottom + + + Right + + + + + + + */} + { + router.push(`/${orgShortCode}/${spaceData.shortcode}/settings`); + }}> + Space Settings + + + +
+
+ ); +} + function OrgMenu() { const setSidebarSubmenuOpen = useSetAtom(sidebarSubmenuOpenAtom); const orgShortcode = useOrgShortcode(); @@ -106,15 +201,15 @@ function OrgMenu() { 'bg-base-1 border-base-5 hover:bg-base-2 flex w-full flex-row items-center justify-between gap-2 rounded-lg border p-3 shadow-sm' }>
- {isLoading || !currentOrg ? ( -
- -
- ) : ( -
+
+ {isLoading || !currentOrg ? ( +
+ +
+ ) : ( -
- )} + )} +
{ - router.push(`/${org.shortcode}/convo`); + router.push(`/${org.shortcode}/personal/convo`); }} className={ 'flex w-full cursor-pointer flex-row items-center justify-between gap-2' @@ -460,6 +555,23 @@ export function OrgMenuContent() { export function SpacesNav() { const { scopedUrl } = useOrgScopedRouter(); + const orgShortcode = useOrgShortcode(); + + const { data: unsortedSpaceData } = + platform.spaces.getOrgMemberSpaces.useQuery({ + orgShortcode + }); + + // sort the spaceData to have the personal space at the top + const spaceData = unsortedSpaceData?.spaces.sort((a, b) => { + if (a.publicId === unsortedSpaceData.personalSpaceId) { + return -1; + } + if (b.publicId === unsortedSpaceData.personalSpaceId) { + return 1; + } + return 0; + }); return (
- + Spaces - -
- + + {spaceData && spaceData?.length > 1 && ( +
+ +
+ +
+ + All Conversations + +
- My personal space - + )} + + {spaceData + ?.filter( + (space) => space.publicId === unsortedSpaceData?.personalSpaceId + ) + .map((space) => ( + + ))} + + + + Shared Spaces + + + {spaceData + ?.filter( + (space) => space.publicId !== unsortedSpaceData?.personalSpaceId + ) + .map((space) => ( + + ))} + +
); diff --git a/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/convo-views.tsx b/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/convo-views.tsx index c202cd3d..3f7d0fa4 100644 --- a/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/convo-views.tsx +++ b/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/convo-views.tsx @@ -6,6 +6,7 @@ import { type VirtuosoHandle } from 'react-virtuoso'; import { useCallback, useMemo, useRef } from 'react'; import { formatParticipantData } from '../../utils'; import { SpinnerGap } from '@phosphor-icons/react'; +import { useSearchParams } from 'next/navigation'; import { type TypeId } from '@u22n/utils/typeid'; import { MessagesPanel } from './messages-panel'; import { platform } from '@/src/lib/trpc'; @@ -26,7 +27,7 @@ export function ConvoView({ convoId }: { convoId: TypeId<'convos'> }) { convoPublicId: convoId }, { - staleTime: ms('1 minute') + staleTime: ms('10 minutes') } ); @@ -47,15 +48,15 @@ export function ConvoView({ convoId }: { convoId: TypeId<'convos'> }) { }, [convoData, orgShortcode]); const participantOwnPublicId = convoData?.ownParticipantPublicId; - const convoHidden = useMemo( - () => - convoData - ? (convoData?.data.participants.find( - (p) => p.publicId === participantOwnPublicId - )?.hidden ?? false) - : null, - [convoData, participantOwnPublicId] - ); + // const convoHidden = useMemo( + // () => + // convoData + // ? (convoData?.data.participants.find( + // (p) => p.publicId === participantOwnPublicId + // )?.hidden ?? false) + // : null, + // [convoData, participantOwnPublicId] + // ); const formattedParticipants = useMemo(() => { const formattedParticipantsData: NonNullable< @@ -90,12 +91,12 @@ export function ConvoView({ convoId }: { convoId: TypeId<'convos'> }) { - {convoDataLoading || !participantOwnPublicId ? ( + {convoDataLoading ? (
}) { convoId={convoId} formattedParticipants={formattedParticipants} participantOwnPublicId={ - participantOwnPublicId as TypeId<'convoParticipants'> + participantOwnPublicId as TypeId<'convoParticipants'> | null } ref={virtuosoRef} /> @@ -127,13 +128,20 @@ export function ConvoView({ convoId }: { convoId: TypeId<'convos'> }) { } export function ConvoNotFound() { - usePageTitle('Convo Not Found'); + const searchParams = useSearchParams(); + const wasDeleted = searchParams.get('deleted') === 'true'; + + usePageTitle(wasDeleted ? 'Convo Deleted' : 'Convo Not Found'); return (
-
Convo Not Found
+
+ {wasDeleted ? 'Convo Deleted' : 'Convo Not Found'} +
- The convo you are looking for does not exist or has been deleted. + {wasDeleted + ? 'The convo you are looking for has been deleted.' + : 'The convo you are looking for does not exist.'}
); diff --git a/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/messages-panel.tsx b/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/messages-panel.tsx index 93114380..7d9c0a9a 100644 --- a/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/messages-panel.tsx +++ b/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/messages-panel.tsx @@ -41,7 +41,7 @@ import { toast } from 'sonner'; type MessagesPanelProps = { convoId: TypeId<'convos'>; - participantOwnPublicId: TypeId<'convoParticipants'>; + participantOwnPublicId: TypeId<'convoParticipants'> | null; formattedParticipants: NonNullable< ReturnType >[]; @@ -220,7 +220,7 @@ const MessageItem = memo( formattedParticipants }: { message: RouterOutputs['convos']['entries']['getConvoEntries']['entries'][number]; - participantOwnPublicId: string; + participantOwnPublicId: string | null; formattedParticipants: NonNullable< ReturnType >[]; diff --git a/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/participants.tsx b/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/participants.tsx index 2af83483..cc7bdcaf 100644 --- a/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/participants.tsx +++ b/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/participants.tsx @@ -12,6 +12,11 @@ import { HoverCardContent, HoverCardTrigger } from '@/src/components/shadcn-ui/hover-card'; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from '@/src/components/shadcn-ui/tooltip'; import { Separator } from '@/src/components/shadcn-ui/separator'; import { Avatar, AvatarIcon } from '@/src/components/avatar'; import { Button } from '@/src/components/shadcn-ui/button'; @@ -57,36 +62,41 @@ export const Participants = memo(function Participants({ direction="right" noBodyStyles shouldScaleBackground={false}> - -
- {orderedParticipants.map((participant) => ( + + +
-
- -
+ className={ + 'hover:text-base-12 text-base-11 flex h-6 min-h-6 w-fit cursor-pointer flex-row items-center gap-0.5 p-0' + }> + {orderedParticipants.map((participant) => ( +
+
+ +
+
+ ))}
- ))} -
-
+ + + View Conversation Participants +
diff --git a/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/reply-box.tsx b/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/reply-box.tsx index f90c2829..d637b5c1 100644 --- a/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/reply-box.tsx +++ b/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/reply-box.tsx @@ -27,10 +27,10 @@ import { type EditorFunctions } from '@u22n/tiptap/components'; import { useAttachmentUploader } from '@/src/components/shared/attachments'; +import { useOrgShortcode, useSpaceShortcode } from '@/src/hooks/use-params'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { emailIdentityAtom, replyToMessageAtom } from '../atoms'; import { Button } from '@/src/components/shadcn-ui/button'; -import { useOrgShortcode } from '@/src/hooks/use-params'; import { useIsMobile } from '@/src/hooks/use-is-mobile'; import { emptyTiptapEditorContent } from '@u22n/tiptap'; import { useDraft } from '@/src/stores/draft-store'; @@ -57,6 +57,7 @@ export function ReplyBox({ const { draft, setDraft, resetDraft } = useDraft(convoId); const [editorText, setEditorText] = useState(draft.content); const orgShortcode = useOrgShortcode(); + const spaceShortcode = useSpaceShortcode(false); const replyTo = useAtomValue(replyToMessageAtom); const addConvoToCache = useUpdateConvoMessageList$Cache(); const updateConvoData = useUpdateConvoData$Cache(); @@ -96,12 +97,15 @@ export function ReplyBox({ const { data: emailIdentities, isLoading: emailIdentitiesLoading } = platform.org.mail.emailIdentities.getUserEmailIdentities.useQuery( { - orgShortcode + orgShortcode, + spaceShortcode, + convoPublicId: convoId }, { staleTime: ms('1 hour') } ); + const { data: isAdmin } = platform.org.users.members.isOrgMemberAdmin.useQuery( { @@ -158,21 +162,29 @@ export function ReplyBox({ messageType: type, sendAsEmailIdentityPublicId: emailIdentity ?? undefined }); - await addConvoToCache(convoId, publicId); - await updateConvoData(convoId, (oldData) => { - const author = oldData.participants.find( - (participant) => - participant.publicId === oldData.entries[0]?.author.publicId - ); - if (!author) return oldData; - const newEntry: (typeof oldData.entries)[0] = { - author: structuredClone(author), - bodyPlainText, - type - }; - oldData.lastUpdatedAt = new Date(); - oldData.entries.unshift(newEntry); - return oldData; + await addConvoToCache({ + convoId, + convoEntryPublicId: publicId, + spaceShortcode: spaceShortcode ?? 'personal' + }); + await updateConvoData({ + convoId, + dataUpdater: (oldData) => { + const author = oldData.participants.find( + (participant) => + participant.publicId === oldData.entries[0]?.author.publicId + ); + if (!author) return oldData; + const newEntry: (typeof oldData.entries)[0] = { + author: structuredClone(author), + bodyPlainText, + type + }; + oldData.lastUpdatedAt = new Date(); + oldData.entries.unshift(newEntry); + return oldData; + }, + spaceShortcode: spaceShortcode ?? 'personal' }); onReply(); @@ -188,6 +200,7 @@ export function ReplyBox({ orgShortcode, replyTo, replyToConvo, + spaceShortcode, updateConvoData ] ); diff --git a/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/top-bar.tsx b/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/top-bar.tsx index b1f9f3a1..870fd04c 100644 --- a/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/top-bar.tsx +++ b/apps/web/src/app/[orgShortcode]/convo/[convoId]/_components/top-bar.tsx @@ -1,8 +1,8 @@ 'use client'; import { - EyeSlash, - Eye, + // EyeSlash, + // Eye, Trash, FilePdf, FileDoc, @@ -13,7 +13,13 @@ import { FileZip, FileTxt, File, - ArrowLeft + ArrowLeft, + SquaresFour, + CaretRight, + Circle, + Check, + ArrowSquareOut, + ArrowSquareIn } from '@phosphor-icons/react'; import { Dialog, @@ -24,27 +30,41 @@ import { DialogHeader, DialogTitle } from '@/src/components/shadcn-ui/dialog'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbSeparator +} from '@/src/components/shadcn-ui/breadcrumb'; import { useCurrentConvoId, useOrgScopedRouter, - useOrgShortcode + useOrgShortcode, + useSpaceShortcode } from '@/src/hooks/use-params'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/src/components/shadcn-ui/tooltip'; +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/src/components/shadcn-ui/popover'; import { useDeleteConvo$Cache, type formatParticipantData } from '../../utils'; import { useModifierKeys } from '@/src/components/modifier-class-provider'; import { type VariantProps, cva } from 'class-variance-authority'; import { type RouterOutputs, platform } from '@/src/lib/trpc'; +import { type SpaceWorkflowType } from '@u22n/utils/spaces'; import { Button } from '@/src/components/shadcn-ui/button'; import { useIsMobile } from '@/src/hooks/use-is-mobile'; import { memo, useCallback, useState } from 'react'; +import { type UiColor } from '@u22n/utils/colors'; import { type TypeId } from '@u22n/utils/typeid'; import { Participants } from './participants'; import { cn } from '@/src/lib/utils'; -import { toast } from 'sonner'; +// import { toast } from 'sonner'; import Link from 'next/link'; type TopBarProps = { @@ -57,22 +77,22 @@ type TopBarProps = { }[]; isConvoLoading: boolean; convoId: TypeId<'convos'>; - convoHidden: boolean | null; + // convoHidden: boolean | null; subjects?: RouterOutputs['convos']['getConvo']['data']['subjects']; }; export default function TopBar({ isConvoLoading, convoId, - convoHidden, + // convoHidden, subjects, participants, attachments }: TopBarProps) { - const orgShortcode = useOrgShortcode(); + // const orgShortcode = useOrgShortcode(); const isMobile = useIsMobile(); - const { mutate: hideConvo, isPending: hidingConvo } = - platform.convos.hideConvo.useMutation(); + // const { mutate: hideConvo, isPending: hidingConvo } = + // platform.convos.hideConvo.useMutation(); return (
@@ -98,11 +118,13 @@ export default function TopBar({
+ +
-
+
+
- {attachments.length > 0 ? ( - attachments.map((attachment) => ( - - )) - ) : ( - No Attachments - )} +
+ {attachments.length > 0 ? ( + attachments.map((attachment) => ( + + )) + ) : ( + No Attachments + )} +
); } +type AddToSpaceButtonProps = { + convoId: TypeId<'convos'>; +}; + +function AddToSpaceButton({ convoId }: AddToSpaceButtonProps) { + const [showSpaceList, setShowSpaceList] = useState(false); + const orgShortcode = useOrgShortcode(); + + const convoSpaceQuery = platform.useUtils().convos.getConvoSpaceWorkflows; + + const { data: spaces, isLoading: spacesLoading } = + platform.spaces.getAllOrgSpacesWithPersonalSeparately.useQuery({ + orgShortcode + }); + + const { mutateAsync: addConvoToSpace, isPending } = + platform.convos.addConvoToSpace.useMutation({ + onSuccess: () => { + void convoSpaceQuery.invalidate(); + } + }); + + async function handleAddToSpace(spacePublicId: TypeId<'spaces'>) { + await addConvoToSpace({ + orgShortcode: orgShortcode, + convoPublicId: convoId, + spacePublicId: spacePublicId + }); + setShowSpaceList(false); + } + + return ( + <> + + + + setShowSpaceList(!showSpaceList)}> + + + + Add Conversation to another Space + + setShowSpaceList(false)}> +
+ + Add Conversation to another Space + + {!spacesLoading && + spaces?.personalSpaces && + spaces?.personalSpaces?.length > 0 && ( +
+ + Personal Spaces + + {spaces?.personalSpaces.map((space) => ( + + ))} +
+ )} + {!spacesLoading && + spaces?.orgSpaces && + spaces?.orgSpaces?.length > 0 && ( +
+ + Shared Spaces + + {spaces?.orgSpaces.map((space) => ( + + ))} +
+ )} +
+
+
+ + ); +} + +type MoveToSpaceButtonProps = { + convoId: TypeId<'convos'>; +}; + +function MoveToSpaceButton({ convoId }: MoveToSpaceButtonProps) { + const [showSpaceList, setShowSpaceList] = useState(false); + const orgShortcode = useOrgShortcode(); + + const convoSpaceQuery = platform.useUtils().convos.getConvoSpaceWorkflows; + + const { data: spaces, isLoading: spacesLoading } = + platform.spaces.getAllOrgSpacesWithPersonalSeparately.useQuery({ + orgShortcode + }); + + const { mutateAsync: moveConvoToSpace, isPending } = + platform.convos.moveConvoToSpace.useMutation({ + onSuccess: () => { + void convoSpaceQuery.invalidate(); + } + }); + + async function handleMoveToSpace(spacePublicId: TypeId<'spaces'>) { + await moveConvoToSpace({ + orgShortcode: orgShortcode, + convoPublicId: convoId, + spacePublicId: spacePublicId + }); + setShowSpaceList(false); + } + + return ( + <> + + + + setShowSpaceList(!showSpaceList)}> + + + + + Move Conversation to a different Space + + + setShowSpaceList(false)}> +
+ + Move Conversation to a different Space + + {!spacesLoading && + spaces?.personalSpaces && + spaces?.personalSpaces?.length > 0 && ( +
+ + Personal Spaces + + {spaces?.personalSpaces.map((space) => ( + + ))} +
+ )} + {!spacesLoading && + spaces?.orgSpaces && + spaces?.orgSpaces?.length > 0 && ( +
+ + Shared Spaces + + {spaces?.orgSpaces.map((space) => ( + + ))} +
+ )} +
+
+
+ + ); +} + type DeleteButtonProps = { convoId: TypeId<'convos'>; - hidden: boolean | null; + // hidden: boolean | null; }; const DeleteButton = memo(function DeleteButton({ - convoId, - hidden + convoId + // hidden }: DeleteButtonProps) { const { shiftKey } = useModifierKeys(); const orgShortcode = useOrgShortcode(); @@ -165,12 +446,15 @@ const DeleteButton = memo(function DeleteButton({ const removeConvoFromList = useDeleteConvo$Cache(); const { scopedNavigate } = useOrgScopedRouter(); const currentConvo = useCurrentConvoId(); + const spaceShortcode = useSpaceShortcode(false); const { mutate: deleteConvo, isPending: deletingConvo } = platform.convos.deleteConvo.useMutation({ - onSuccess: () => { - void removeConvoFromList(convoId); - } + onSuccess: () => + removeConvoFromList({ + convoPublicId: convoId, + spaceShortcode: spaceShortcode ?? 'personal' + }) }); const onDelete = useCallback( @@ -178,7 +462,7 @@ const DeleteButton = memo(function DeleteButton({ e.preventDefault(); if (e.shiftKey) { if (currentConvo === convoId) { - scopedNavigate('/convo'); + scopedNavigate('/convo', true); } return deleteConvo({ convoPublicId: convoId, @@ -215,7 +499,7 @@ const DeleteButton = memo(function DeleteButton({ open={deleteModalOpen} setOpen={setDeleteModalOpen} convoId={convoId} - convoHidden={hidden} + // convoHidden={hidden} /> )} @@ -226,38 +510,45 @@ type DeleteModalProps = { open: boolean; setOpen: (open: boolean) => void; convoId: TypeId<'convos'>; - convoHidden: boolean | null; + // convoHidden: boolean | null; }; function DeleteModal({ open, convoId, - convoHidden, + // convoHidden, setOpen }: DeleteModalProps) { const orgShortcode = useOrgShortcode(); const { scopedNavigate } = useOrgScopedRouter(); const currentConvo = useCurrentConvoId(); + const spaceShortcode = useSpaceShortcode(false); const removeConvoFromList = useDeleteConvo$Cache(); - const { mutate: hideConvo, isPending: hidingConvo } = - platform.convos.hideConvo.useMutation({ - onSuccess: () => { - void removeConvoFromList(convoId); - setOpen(false); - }, - onError: (error) => { - toast.error('Something went wrong while hiding the convo', { - description: error.message - }); - setOpen(false); - } - }); + // const { mutate: hideConvo, isPending: hidingConvo } = + // platform.convos.hideConvo.useMutation({ + // onSuccess: async () => { + // await removeConvoFromList({ + // convoPublicId: convoId, + // spaceShortcode: spaceShortcode ?? 'personal' + // }); + // setOpen(false); + // }, + // onError: (error) => { + // toast.error('Something went wrong while hiding the convo', { + // description: error.message + // }); + // setOpen(false); + // } + // }); const { mutate: deleteConvo, isPending: deletingConvo } = platform.convos.deleteConvo.useMutation({ - onSuccess: () => { - void removeConvoFromList(convoId); + onSuccess: async () => { + await removeConvoFromList({ + convoPublicId: convoId, + spaceShortcode: spaceShortcode ?? 'personal' + }); setOpen(false); } }); @@ -266,7 +557,7 @@ function DeleteModal({ { - if (deletingConvo || hidingConvo) return; + if (deletingConvo) return; setOpen(false); }}> @@ -278,11 +569,11 @@ function DeleteModal({ all the participants. Are you sure you want to delete this conversation? - {!convoHidden && ( + {/* {!convoHidden && ( You can also choose to hide this Convo - )} + )} */} ProTip: Hold{' '} Shift next @@ -296,11 +587,11 @@ function DeleteModal({ - {convoHidden ? null : ( + {/* {convoHidden ? null : ( - )} + )} */} + + setShowWorkflowList(false)}> +
+
+ Open + {spaceWorkflows.open.map((spaceWorkflow: SpaceWorkflowData) => ( + handleSetConvoWorkflow(spaceWorkflow.publicId)} + /> + ))} +
+
+ Active + {spaceWorkflows.active.map((spaceWorkflow: SpaceWorkflowData) => ( + handleSetConvoWorkflow(spaceWorkflow.publicId)} + /> + ))} +
+
+ Closed + {spaceWorkflows.closed.map((spaceWorkflow: SpaceWorkflowData) => ( + handleSetConvoWorkflow(spaceWorkflow.publicId)} + /> + ))} +
+
+
+ + + ); +} + +function WorkflowItem({ + workflow, + activeWorkflowPublicId, + handler +}: { + workflow: SpaceWorkflowData; + activeWorkflowPublicId: TypeId<'spaceWorkflows'> | null; + handler: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/app/[orgShortcode]/convo/[convoId]/page.tsx b/apps/web/src/app/[orgShortcode]/convo/[convoId]/page.tsx index faffdf60..729b0db7 100644 --- a/apps/web/src/app/[orgShortcode]/convo/[convoId]/page.tsx +++ b/apps/web/src/app/[orgShortcode]/convo/[convoId]/page.tsx @@ -5,8 +5,6 @@ import { useCurrentConvoId } from '@/src/hooks/use-params'; export default function ConvoPage() { const convoId = useCurrentConvoId(); - if (!convoId) { - return ; - } + if (!convoId) return ; return ; } diff --git a/apps/web/src/app/[orgShortcode]/convo/_components/convo-list-base.tsx b/apps/web/src/app/[orgShortcode]/convo/_components/convo-list-base.tsx new file mode 100644 index 00000000..ab393283 --- /dev/null +++ b/apps/web/src/app/[orgShortcode]/convo/_components/convo-list-base.tsx @@ -0,0 +1,206 @@ +'use client'; +import { convoListSelection, lastSelectedConvo } from '../atoms'; +import { memo, useCallback, useEffect, useMemo } from 'react'; +import { useSpaceShortcode } from '@/src/hooks/use-params'; +import { AnimatePresence, motion } from 'framer-motion'; +import { SpinnerGap } from '@phosphor-icons/react'; +import { type TypeId } from '@u22n/utils/typeid'; +import { ConvoItem } from './convo-list-item'; +import { Virtuoso } from 'react-virtuoso'; +import { type Convo } from '../utils'; +import { useAtom } from 'jotai'; + +type ConvoListBaseProps = { + // hidden: boolean; + convos: Convo[]; + isLoading: boolean; + hasNextPage?: boolean; + isFetchingNextPage: boolean; + linkBase: string; + fetchNextPage: () => Promise; +}; + +export function ConvoListBase({ + // hidden, + convos, + isLoading, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + linkBase +}: ConvoListBaseProps) { + const spaceShortcode = useSpaceShortcode(false); + const [selections, setSelections] = useAtom(convoListSelection); + const [lastSelected, setLastSelected] = useAtom(lastSelectedConvo); + + // Reset selections when space changes to avoid cross-space selections + useEffect(() => { + setSelections([]); + setLastSelected(null); + }, [setLastSelected, setSelections, spaceShortcode]); + + const rangeSelect = useCallback( + (upto: TypeId<'convos'>) => { + const isAlreadySelected = selections.includes(upto); + const lastSelectedIndex = lastSelected + ? convos.findIndex((c) => c.publicId === lastSelected) + : -1; + const uptoIndex = convos.findIndex((c) => c.publicId === upto); + const convoRange = convos + .slice( + Math.min(lastSelectedIndex, uptoIndex), + Math.max(lastSelectedIndex, uptoIndex) + 1 + ) + .map((c) => c.publicId); + const totalSelections = isAlreadySelected + ? selections.filter((c) => !convoRange.includes(c)) + : selections.concat(convoRange); + setSelections(Array.from(new Set(totalSelections))); + setLastSelected(upto); + }, + [lastSelected, convos, setLastSelected, selections, setSelections] + ); + + const onSelect = useCallback( + (convo: Convo, shiftKey: boolean, selected: boolean) => { + if (shiftKey) { + rangeSelect(convo.publicId); + } else { + setSelections((prev) => + selected + ? prev.filter((c) => c !== convo.publicId) + : prev.concat(convo.publicId) + ); + setLastSelected(convo.publicId); + } + }, + [rangeSelect, setLastSelected, setSelections] + ); + + const itemRenderer = useCallback( + (_: number, convo: Convo) => { + const selected = selections.includes(convo.publicId); + return ( +
+ +
+ +
); } diff --git a/apps/web/src/app/[orgShortcode]/convo/_components/delete-convos-modal.tsx b/apps/web/src/app/[orgShortcode]/convo/_components/delete-convos-modal.tsx index 2d749c94..38ed3a4d 100644 --- a/apps/web/src/app/[orgShortcode]/convo/_components/delete-convos-modal.tsx +++ b/apps/web/src/app/[orgShortcode]/convo/_components/delete-convos-modal.tsx @@ -11,7 +11,8 @@ import { import { useCurrentConvoId, useOrgScopedRouter, - useOrgShortcode + useOrgShortcode, + useSpaceShortcode } from '@/src/hooks/use-params'; import { Button } from '@/src/components/shadcn-ui/button'; import { useDeleteConvo$Cache } from '../utils'; @@ -31,12 +32,16 @@ export function DeleteMultipleConvosModal({ const currentConvo = useCurrentConvoId(); const { scopedNavigate } = useOrgScopedRouter(); const [selections, setSelections] = useAtom(convoListSelection); + const spaceShortcode = useSpaceShortcode(false); const { mutate: deleteConvo, isPending: deletingConvos } = platform.convos.deleteConvo.useMutation({ onSuccess: async () => { setOpen(false); - await deleteConvoCache(selections); + await deleteConvoCache({ + convoPublicId: selections, + spaceShortcode: spaceShortcode ?? 'personal' + }); setSelections([]); } }); @@ -73,7 +78,7 @@ export function DeleteMultipleConvosModal({ loading={deletingConvos} onClick={() => { if (currentConvo && selections.includes(currentConvo)) { - scopedNavigate('/convo'); + scopedNavigate('/convo', true); } deleteConvo({ orgShortcode, diff --git a/apps/web/src/app/[orgShortcode]/convo/_components/new-convo-sheet.tsx b/apps/web/src/app/[orgShortcode]/convo/_components/new-convo-sheet.tsx index b9485ea1..5dcc7626 100644 --- a/apps/web/src/app/[orgShortcode]/convo/_components/new-convo-sheet.tsx +++ b/apps/web/src/app/[orgShortcode]/convo/_components/new-convo-sheet.tsx @@ -50,7 +50,7 @@ export function NewConvoSheet() { size={'icon'} variant={'ghost'} onClick={() => { - scopedNavigate('/convo/new'); + scopedNavigate('/convo/new', true); setOpen(false); }}> >; -}) { +function ConvoNavHeader( + { + // showHidden, + // setShowHidden + }: { + // showHidden: boolean; + // setShowHidden: Dispatch>; + } +) { const orgShortcode = useOrgShortcode(); + const spaceShortcode = useSpaceShortcode(); const { scopedUrl } = useOrgScopedRouter(); - const { mutate: hideConvo } = platform.convos.hideConvo.useMutation({ - onSettled: () => { - setSelection([]); - } - }); + // const { mutate: hideConvo } = platform.convos.hideConvo.useMutation({ + // onSettled: () => { + // setSelection([]); + // } + // }); - const adminIssuesCache = platform.useUtils().org.store.getOrgIssues; + const spaceDisplayPropertiesQuery = + platform.spaces.getSpaceDisplayProperties.useQuery({ + orgShortcode: orgShortcode, + spaceShortcode: spaceShortcode + }); - const addConvo = useAddSingleConvo$Cache(); - const toggleConvoHidden = useToggleConvoHidden$Cache(); - const deleteConvo = useDeleteConvo$Cache(); - const updateConvoMessageList = useUpdateConvoMessageList$Cache(); - const client = useRealtime(); + const { + data: spaceDisplayProperties, + isLoading: spaceDisplayPropertiesLoading, + error: spaceDisplayPropertiesError + } = spaceDisplayPropertiesQuery; const pathname = usePathname(); const setNewPanelOpen = useSetAtom(showNewConvoPanel); const selectingMode = useAtomValue(convoListSelecting); const [selection, setSelection] = useAtom(convoListSelection); - useEffect(() => { - client.on('convo:new', ({ publicId }) => addConvo(publicId)); - client.on('convo:hidden', ({ publicId, hidden }) => - toggleConvoHidden(publicId, hidden) - ); - client.on('convo:deleted', ({ publicId }) => deleteConvo(publicId)); - client.on('convo:entry:new', ({ convoPublicId, convoEntryPublicId }) => - updateConvoMessageList(convoPublicId, convoEntryPublicId) - ); - client.on( - 'admin:issue:refresh', - async () => void adminIssuesCache.refetch() - ); - - return () => { - client.off('convo:new'); - client.off('convo:hidden'); - client.off('convo:deleted'); - client.off('convo:entry:new'); - client.off('admin:issue:refresh'); - }; - }, [ - client, - addConvo, - toggleConvoHidden, - deleteConvo, - updateConvoMessageList, - adminIssuesCache - ]); - const isInConvo = !pathname.endsWith('/convo') && !pathname.endsWith('/convo/new'); @@ -172,7 +146,7 @@ function ConvoNavHeader({ - + */}
) : ( @@ -202,13 +176,31 @@ function ConvoNavHeader({ -
- -
- + ) : spaceDisplayPropertiesError ? ( + Unnamed Space + ) : ( +
+
+ +
+ + {spaceDisplayProperties?.space?.name ?? 'Unnamed Space'} + +
+ )}
@@ -221,8 +213,9 @@ function ConvoNavHeader({ />
- - {showHidden ? 'Hidden Conversations' : 'Conversations'} + + Conversations + {/* {showHidden ? 'Hidden Conversations' : 'Conversations'} */} @@ -230,18 +223,18 @@ function ConvoNavHeader({
- + */} {!isInConvo ? ( ) : (
); } + +export default function Layout({ children }: { children: React.ReactNode }) { + // const [showHidden, setShowHidden] = useState(false); + const convoList = useMemo( + () => ( +