From 00d1732f4b90af19563a8b34ca57623c8d5d701e Mon Sep 17 00:00:00 2001 From: McPizza0 Date: Fri, 5 Jul 2024 13:05:58 +0100 Subject: [PATCH] feat-spaces --- apps/platform/app.ts | 12 +- apps/platform/trpc/index.ts | 4 +- .../trpc/routers/convoRouter/convoRouter.ts | 13 - .../orgRouter/iCanHaz/iCanHazRouter.ts | 10 + .../trpc/routers/orgRouter/orgCrudRouter.ts | 64 ++- .../routers/orgRouter/users/invitesRouter.ts | 51 ++- .../routers/spaceRouter/spaceMemberRouter.ts | 17 + .../trpc/routers/spaceRouter/spaceRouter.ts | 374 ++++++++++++++++ .../trpc/routers/userRouter/profileRouter.ts | 97 ++-- apps/platform/trpc/trpc.ts | 48 ++ .../[orgShortCode]/[spaceShortCode]/page.tsx | 29 ++ .../[spaceShortCode]/settings/page.tsx | 29 ++ .../_components/new-space-modal.tsx | 354 +++++++++++++++ .../_components/sidebar-content.tsx | 85 +++- apps/web/src/components/shadcn-ui/button.tsx | 2 +- apps/web/src/components/shadcn-ui/dialog.tsx | 4 +- apps/web/src/components/shadcn-ui/select.tsx | 7 +- ee/apps/billing/trpc/routers/iCanHazRouter.ts | 24 + packages/database/schema.ts | 422 +++++++++++++++++- packages/utils/typeid.ts | 35 +- 20 files changed, 1569 insertions(+), 112 deletions(-) create mode 100644 apps/platform/trpc/routers/spaceRouter/spaceMemberRouter.ts create mode 100644 apps/platform/trpc/routers/spaceRouter/spaceRouter.ts create mode 100644 apps/web/src/app/[orgShortCode]/[spaceShortCode]/page.tsx create mode 100644 apps/web/src/app/[orgShortCode]/[spaceShortCode]/settings/page.tsx create mode 100644 apps/web/src/app/[orgShortcode]/_components/new-space-modal.tsx diff --git a/apps/platform/app.ts b/apps/platform/app.ts index b6946ad9..9c1e6e24 100644 --- a/apps/platform/app.ts +++ b/apps/platform/app.ts @@ -6,7 +6,7 @@ import { serve } from '@hono/node-server'; import { cors } from 'hono/cors'; import { authApi } from './routes/auth'; import { realtimeApi } from './routes/realtime'; -import { trpcPlatformRouter } from './trpc'; +import { TRPCError, trpcPlatformRouter } from './trpc'; import { db } from '@u22n/database'; import { trpcServer } from '@hono/trpc-server'; import { authMiddleware, serviceMiddleware } from './middlewares'; @@ -66,7 +66,15 @@ app.use( org: null, event: c, selfHosted: !env.EE_LICENSE_KEY - }) satisfies TrpcContext + }) satisfies TrpcContext, + onError: (err) => { + if (err instanceof TRPCError) throw err; + console.error(err); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Something went wrong' + }); + } }) ); diff --git a/apps/platform/trpc/index.ts b/apps/platform/trpc/index.ts index 80366445..9be747ee 100644 --- a/apps/platform/trpc/index.ts +++ b/apps/platform/trpc/index.ts @@ -21,6 +21,7 @@ import { twoFactorRouter } from './routers/authRouter/twoFactorRouter'; import { securityRouter } from './routers/userRouter/securityRouter'; import { storeRouter } from './routers/orgRouter/orgStoreRouter'; import { iCanHazRouter } from './routers/orgRouter/iCanHaz/iCanHazRouter'; +import { spaceRouter } from './routers/spaceRouter/spaceRouter'; const trpcPlatformAuthRouter = router({ signup: signupRouter, @@ -66,7 +67,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 296efd5f..8807a785 100644 --- a/apps/platform/trpc/routers/convoRouter/convoRouter.ts +++ b/apps/platform/trpc/routers/convoRouter/convoRouter.ts @@ -1377,19 +1377,6 @@ export const convoRouter = router({ }; }), - //* get convo entries - // getConvoEntries: orgProcedure - // .input( - // z.object({ - // convoPublicId: typeIdValidator('convos'), - // cursor: z.object({ - // lastUpdatedAt: z.date().optional(), - // lastPublicId: typeIdValidator('convos').optional() - // }) - // }) - // ) - // .query(async () => {}), - getOrgMemberConvos: orgProcedure .input( z.object({ diff --git a/apps/platform/trpc/routers/orgRouter/iCanHaz/iCanHazRouter.ts b/apps/platform/trpc/routers/orgRouter/iCanHaz/iCanHazRouter.ts index 4e793367..0ca902c6 100644 --- a/apps/platform/trpc/routers/orgRouter/iCanHaz/iCanHazRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/iCanHaz/iCanHazRouter.ts @@ -16,6 +16,16 @@ 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, + shared: true + }; + } + return await billingTrpcClient.iCanHaz.space.query({ orgId: org.id }); }) }); diff --git a/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts b/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts index 84168e07..635b422b 100644 --- a/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts @@ -6,11 +6,14 @@ import { orgs, orgMembers, orgMemberProfiles, - accounts + accounts, + spaces, + spaceMembers } from '@u22n/database/schema'; import { typeIdGenerator } from '@u22n/utils/typeid'; import { TRPCError } from '@trpc/server'; import { blockedUsernames, reservedUsernames } from '~platform/utils/signup'; +import { validateSpaceShortCode } from '../spaceRouter/spaceRouter'; async function validateOrgShortcode( db: DBType, @@ -94,17 +97,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({ @@ -125,7 +126,7 @@ export const crudRouter = router({ .insert(orgMemberProfiles) .values({ orgId: orgId, - publicId: newProfilePublicId, + publicId: typeIdGenerator('orgMemberProfile'), accountId: accountId, firstName: username, lastName: '', @@ -135,7 +136,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', @@ -144,8 +145,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: 'shared', + 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, + canChangeStatus: true, + canSetStatusToClosed: 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 0b53ba9b..814874b4 100644 --- a/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/users/invitesRouter.ts @@ -16,7 +16,9 @@ import { orgInvitations, orgMembers, orgMemberProfiles, - accounts + accounts, + spaces, + spaceMembers } from '@u22n/database/schema'; import { typeIdGenerator, typeIdValidator } from '@u22n/utils/typeid'; import { nanoIdToken, zodSchemas } from '@u22n/utils/zodSchemas'; @@ -28,6 +30,7 @@ import { addOrgMemberToTeamHandler } from './teamsHandler'; import { sendInviteEmail } from '~platform/utils/mail/transactional'; import { env } from '~platform/env'; import { ratelimiter } from '~platform/trpc/ratelimit'; +import { validateSpaceShortCode } from '../../spaceRouter/spaceRouter'; export const invitesRouter = router({ createNewInvite: orgProcedure @@ -98,6 +101,52 @@ 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: 'shared', + 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, + canChangeStatus: true, + canSetStatusToClosed: 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))); + // Insert teamMemberships - save ID if (teamsInput) { for (const teamPublicId of teamsInput.teamsPublicIds) { diff --git a/apps/platform/trpc/routers/spaceRouter/spaceMemberRouter.ts b/apps/platform/trpc/routers/spaceRouter/spaceMemberRouter.ts new file mode 100644 index 00000000..35a1adc2 --- /dev/null +++ b/apps/platform/trpc/routers/spaceRouter/spaceMemberRouter.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; +import { router, accountProcedure } from '~platform/trpc/trpc'; +import type { DBType } from '@u22n/database'; +import { eq, and } from '@u22n/database/orm'; +import { + orgs, + orgMembers, + orgMemberProfiles, + accounts, + spaces, + spaceMembers +} from '@u22n/database/schema'; +import { typeIdGenerator } from '@u22n/utils/typeid'; +import { TRPCError } from '@trpc/server'; +import { blockedUsernames, reservedUsernames } from '~platform/utils/signup'; + +export const spaceMemberRouter = 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..a3bea2f5 --- /dev/null +++ b/apps/platform/trpc/routers/spaceRouter/spaceRouter.ts @@ -0,0 +1,374 @@ +import { z } from 'zod'; +import { router, accountProcedure, orgProcedure } from '~platform/trpc/trpc'; +import type { DBType } from '@u22n/database'; +import { eq, and, like, inArray, or } from '@u22n/database/orm'; +import { + orgs, + orgMembers, + orgMemberProfiles, + accounts, + spaces, + spaceMembers, + teamMembers +} from '@u22n/database/schema'; +import { typeIdGenerator } from '@u22n/utils/typeid'; +import { TRPCError } from '@trpc/server'; +import { spaceMemberRouter } from './spaceMemberRouter'; +import { uiColors } from '@u22n/utils/colors'; +import { iCanHazCallerFactory } from '../orgRouter/iCanHaz/iCanHazRouter'; + +// 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 || !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 lowerShortcode = shortcode.toLowerCase(); + //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 === lowerShortcode) { + return { + shortcode: lowerShortcode + }; + } + } + } + + const existingSpaces = await db.query.spaces.findMany({ + where: and( + eq(spaces.orgId, orgId), + like(spaces.shortcode, `${lowerShortcode}%`) + ), + columns: { + id: true, + shortcode: true + } + }); + + if (existingSpaces.length === 0) { + return { + shortcode: lowerShortcode + }; + } + + const existingShortcodes = existingSpaces.map((space) => space.shortcode); + + let currentSuffix = existingSpaces.length; + let retries = 0; + let validatedShortcode = `${lowerShortcode}-${currentSuffix}`; + while (retries < 30) { + if (existingShortcodes.includes(validatedShortcode)) { + retries++; + currentSuffix++; + validatedShortcode = `${lowerShortcode}-${currentSuffix}`; + continue; + } + break; + } + + return { + shortcode: validatedShortcode + }; +} + +export const spaceRouter = router({ + members: spaceMemberRouter, + 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 + }; + }), + + 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', 'shared']) + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, account, org } = ctx; + const { spaceName, spaceDescription, spaceColor, spaceType } = input; + const accountId = account.id; + + const iCanHazCaller = iCanHazCallerFactory(ctx); + + const canHazSpaces = await iCanHazCaller.space({ + orgShortCode: input.orgShortCode + }); + if ( + (spaceType === 'shared' && !canHazSpaces.shared) || + (spaceType === 'open' && !canHazSpaces.open) + ) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `You cannot create ${spaceType === 'shared' ? 'a shared' : '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 + }; + }), + + 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 + }; + }) +}); diff --git a/apps/platform/trpc/routers/userRouter/profileRouter.ts b/apps/platform/trpc/routers/userRouter/profileRouter.ts index 200d693b..578912fe 100644 --- a/apps/platform/trpc/routers/userRouter/profileRouter.ts +++ b/apps/platform/trpc/routers/userRouter/profileRouter.ts @@ -1,48 +1,17 @@ import { z } from 'zod'; import { router, accountProcedure } from '~platform/trpc/trpc'; import { and, eq } from '@u22n/database/orm'; -import { orgMemberProfiles, orgs, orgMembers } from '@u22n/database/schema'; +import { + orgMemberProfiles, + orgs, + orgMembers, + spaces +} from '@u22n/database/schema'; import { typeIdValidator } from '@u22n/utils/typeid'; import { TRPCError } from '@trpc/server'; +import { validateSpaceShortCode } from '../spaceRouter/spaceRouter'; export const profileRouter = router({ - // createProfile: accountProcedure - // .input( - // z.object({ - // fName: z.string(), - // lName: z.string(), - // handle: z.string().min(2).max(20), - // defaultProfile: z.boolean().optional().default(false) - // }) - // ) - // .mutation(async ({ ctx, input }) => { - // const { db, user } = ctx; - // const userId = user.id; - - // const newPublicId = typeIdGenerator('orgMemberProfile'); - // const insertUserProfileResponse = await db.insert(orgMemberProfiles).values({ - // userId: userId, - // publicId: newPublicId, - // firstName: input.fName, - // lastName: input.lName, - // defaultProfile: input.defaultProfile, - // handle: input.handle - // }); - - // if (!insertUserProfileResponse.insertId) { - // return { - // success: false, - // profileId: null, - // error: - // 'Something went wrong, please retry. Contact our team if it persists' - // }; - // } - // return { - // success: true, - // profileId: newPublicId, - // error: null - // }; - // }), getOrgMemberProfile: accountProcedure .input( z.object({ @@ -128,6 +97,33 @@ export const profileRouter = router({ const { db, account } = ctx; const accountId = account.id; + 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, + personalSpace: true + } + } + } + }); + + if (!orgMemberProfileQuery) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Profile not found' + }); + } + await db .update(orgMemberProfiles) .set({ @@ -137,12 +133,25 @@ 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.personalSpace) { + const validatedShortcode = await validateSpaceShortCode({ + db: db, + shortcode: `${input.handle}-personal`, + orgId: orgMemberProfileQuery.orgId, + spaceId: orgMemberProfileQuery.orgMember.personalSpace + }); + + await db + .update(spaces) + .set({ + name: `${input.fName}'s Personal Space`, + shortcode: validatedShortcode.shortcode, + description: `${input.fName}${input.lName ? ' ' + input.lName : ''}'s Personal Space` + }) + .where(eq(spaces.id, orgMemberProfileQuery.orgMember.personalSpace)); + } return { success: true diff --git a/apps/platform/trpc/trpc.ts b/apps/platform/trpc/trpc.ts index 9205c5fd..8ca00b71 100644 --- a/apps/platform/trpc/trpc.ts +++ b/apps/platform/trpc/trpc.ts @@ -111,6 +111,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/web/src/app/[orgShortCode]/[spaceShortCode]/page.tsx b/apps/web/src/app/[orgShortCode]/[spaceShortCode]/page.tsx new file mode 100644 index 00000000..714bf190 --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/[spaceShortCode]/page.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useGlobalStore } from '@/src/providers/global-store-provider'; +import { platform } from '@/src/lib/trpc'; + +export default function ShorcodeTestPage() { + const orgShortCode = useGlobalStore((state) => state.currentOrg.shortCode); + + const spaceShortcode = useParams().spaceShortCode; + + const { data: spaceData } = platform.spaces.getOrgMemberSpaces.useQuery({ + orgShortCode + }); + const { data: allSpaceData } = platform.spaces.getAllOrgSpaces.useQuery({ + orgShortCode + }); + + return ( +
+ + Hello {spaceShortcode} {JSON.stringify(spaceData)} + + + Hello {spaceShortcode} {JSON.stringify(allSpaceData, null, '\t')} + +
+ ); +} 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..714bf190 --- /dev/null +++ b/apps/web/src/app/[orgShortCode]/[spaceShortCode]/settings/page.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useGlobalStore } from '@/src/providers/global-store-provider'; +import { platform } from '@/src/lib/trpc'; + +export default function ShorcodeTestPage() { + const orgShortCode = useGlobalStore((state) => state.currentOrg.shortCode); + + const spaceShortcode = useParams().spaceShortCode; + + const { data: spaceData } = platform.spaces.getOrgMemberSpaces.useQuery({ + orgShortCode + }); + const { data: allSpaceData } = platform.spaces.getAllOrgSpaces.useQuery({ + orgShortCode + }); + + return ( +
+ + Hello {spaceShortcode} {JSON.stringify(spaceData)} + + + Hello {spaceShortcode} {JSON.stringify(allSpaceData, null, '\t')} + +
+ ); +} diff --git a/apps/web/src/app/[orgShortcode]/_components/new-space-modal.tsx b/apps/web/src/app/[orgShortcode]/_components/new-space-modal.tsx new file mode 100644 index 00000000..73d5a644 --- /dev/null +++ b/apps/web/src/app/[orgShortcode]/_components/new-space-modal.tsx @@ -0,0 +1,354 @@ +'use client'; + +import { platform } from '@/src/lib/trpc'; +import { useGlobalStore } from '@/src/providers/global-store-provider'; +import { useForm } from '@tanstack/react-form'; +import { Input } from '@/src/components/shadcn-ui/input'; +import { Switch } from '@/src/components/shadcn-ui/switch'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue +} from '@/src/components/shadcn-ui/select'; +import { Button } from '@/src/components/shadcn-ui/button'; +import { z } from 'zod'; +import { zodValidator } from '@tanstack/zod-form-adapter'; +import { type TypeId } from '@u22n/utils/typeid'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTrigger, + DialogTitle, + DialogDescription, + DialogClose +} from '@/src/components/shadcn-ui/dialog'; +import { useState } from 'react'; +import { type UiColor, uiColors } from '@u22n/utils/colors'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from '@/src/components/shadcn-ui/tooltip'; +import { + Check, + Globe, + Plus, + SquaresFour, + UsersThree +} from '@phosphor-icons/react'; +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/src/components/shadcn-ui/popover'; +import { useRouter } from 'next/navigation'; + +export function NewSpaceModal() { + const orgShortCode = useGlobalStore((state) => state.currentOrg.shortCode); + const invalidateSpaces = platform.useUtils().spaces.getOrgMemberSpaces; + + const [formError, setFormError] = useState(null); + const router = useRouter(); + + const { data: canAddSpace } = platform.org.iCanHaz.space.useQuery( + { + orgShortCode: orgShortCode + }, + { + staleTime: 1000 + } + ); + const { mutateAsync: createNewSpace, error: spaceError } = + platform.spaces.createNewSpace.useMutation({ + onSuccess: (data) => { + void invalidateSpaces.invalidate(); + setOpen(false); + void router.push(`/${orgShortCode}/${data.spaceShortcode}`); + } + }); + + const form = useForm({ + defaultValues: { + spaceName: '', + description: '', + color: uiColors[Math.floor(Math.random() * uiColors.length)], + type: 'open' as 'open' | 'shared' + }, + validatorAdapter: zodValidator, + onSubmit: async ({ value }) => { + await createNewSpace({ + orgShortCode: orgShortCode, + spaceName: value.spaceName, + spaceDescription: value.description, + spaceColor: value.color ?? 'cyan', + spaceType: value.type + }); + } + }); + + const [open, setOpen] = useState(false); + + return ( + { + if (form.state.isSubmitting) return; + setOpen(!open); + }}> + + + + + + + +

Add new Space

+
+
+
+
+ + + + Create a new Space + + Spaces are where a team, department, or group, can work with their + own Conversations, Statuses and Tags. + + + +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }}> +
+ +
+ ( + + +
+ +
+
+ +
+ {uiColors.map((color) => ( +
field.setValue(color)}> + {field.state.value === color ? ( + + ) : ( + + )} +
+ ))} +
+
+
+ )} + /> + ( +
+ field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {/* {field.state.meta.errorMap.onBlur && ( + + {field.state.meta.errorMap.onBlur} + + )} */} +
+ )} + /> +
+
+
+ + ( + <> + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.errorMap.onBlur && ( + + {field.state.meta.errorMap.onBlur} + + )} + + )} + /> +
+
+ + ( + + )} + /> +
+
{formError ?? spaceError?.message}
+ [ + form.isTouched, + form.canSubmit, + form.isSubmitting + ]} + children={([isTouched, canSubmit, isSubmitting]) => ( + + )} + /> + +
+
+ ); +} diff --git a/apps/web/src/app/[orgShortcode]/_components/sidebar-content.tsx b/apps/web/src/app/[orgShortcode]/_components/sidebar-content.tsx index 9ccef809..14bd5c61 100644 --- a/apps/web/src/app/[orgShortcode]/_components/sidebar-content.tsx +++ b/apps/web/src/app/[orgShortcode]/_components/sidebar-content.tsx @@ -37,7 +37,8 @@ import { Palette, Monitor, Question, - User + User, + SquaresFour } from '@phosphor-icons/react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; @@ -53,9 +54,35 @@ import { ToggleGroupItem } from '@/src/components/shadcn-ui/toggle-group'; import { env } from '@/src/env'; +import { platform } from '@/src/lib/trpc'; +import { type InferQueryLikeData } from '@trpc/react-query/shared'; +import { Button } from '@/src/components/shadcn-ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from '@/src/components/shadcn-ui/tooltip'; +import { NewSpaceModal } from './new-space-modal'; export default function SidebarContent() { const orgShortcode = useGlobalStore((state) => state.currentOrg.shortcode); + 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 - +
+ + Spaces + + +
+ {spaceData?.map((space) => ( + + ))} @@ -93,6 +130,46 @@ export default function SidebarContent() { ); } +type SingleSpaceResponse = InferQueryLikeData< + typeof platform.spaces.getOrgMemberSpaces +>['spaces'][number]; + +function SpaceItem({ + space: spaceData, + isPersonal +}: { + space: SingleSpaceResponse; + isPersonal: boolean; +}) { + const orgShortCode = useGlobalStore((state) => state.currentOrg.shortcode); + const SpaceIcon = () => { + return ( + + ); + }; + + return ( + +
+ +
+ + {isPersonal ? 'My Personal Space' : spaceData.name || 'Unnamed Space'} + + + ); +} + function OrgMenu() { const setCurrentOrg = useGlobalStore((state) => state.setCurrentOrg); const currentOrg = useGlobalStore((state) => state.currentOrg); diff --git a/apps/web/src/components/shadcn-ui/button.tsx b/apps/web/src/components/shadcn-ui/button.tsx index 66908cd3..a11ae5ef 100644 --- a/apps/web/src/components/shadcn-ui/button.tsx +++ b/apps/web/src/components/shadcn-ui/button.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( 'border border-input border-base-7 hover:border-base-8 bg-base-1 hover:bg-base-3 hover:text-base-12 text-base-11', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', + ghost: 'hover:bg-base-4 hover:text-base-12', link: 'text-primary underline-offset-4 hover:underline', child: '' }, diff --git a/apps/web/src/components/shadcn-ui/dialog.tsx b/apps/web/src/components/shadcn-ui/dialog.tsx index 840eddc2..62106a54 100644 --- a/apps/web/src/components/shadcn-ui/dialog.tsx +++ b/apps/web/src/components/shadcn-ui/dialog.tsx @@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef< diff --git a/apps/web/src/components/shadcn-ui/select.tsx b/apps/web/src/components/shadcn-ui/select.tsx index a745fbb2..2d87a831 100644 --- a/apps/web/src/components/shadcn-ui/select.tsx +++ b/apps/web/src/components/shadcn-ui/select.tsx @@ -137,17 +137,16 @@ const SelectItem = React.forwardRef< - + {children} + - - {children} )); SelectItem.displayName = SelectPrimitive.Item.displayName; diff --git a/ee/apps/billing/trpc/routers/iCanHazRouter.ts b/ee/apps/billing/trpc/routers/iCanHazRouter.ts index 006fd9fe..132ce619 100644 --- a/ee/apps/billing/trpc/routers/iCanHazRouter.ts +++ b/ee/apps/billing/trpc/routers/iCanHazRouter.ts @@ -81,5 +81,29 @@ export const iCanHazRouter = router({ return true; } return false; + }), + space: protectedProcedure + .input(z.object({ orgId: z.number() })) + .query(async ({ ctx, input }) => { + const { db } = ctx; + + const orgId = input.orgId; + + const orgBillingResponse = await db.query.orgBilling.findFirst({ + where: eq(orgBilling.orgId, orgId), + columns: { + plan: true + } + }); + if (orgBillingResponse && orgBillingResponse.plan === 'pro') { + return { + open: true, + shared: true + }; + } + return { + open: true, + shared: false + }; }) }); diff --git a/packages/database/schema.ts b/packages/database/schema.ts index ec0dbfef..bf0cffc2 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -370,12 +370,13 @@ export const orgMembers = mysqlTable( 'org_members', { id: serial('id').primaryKey(), + orgId: foreignKey('org_id').notNull(), publicId: publicId('orgMembers', 'public_id').notNull(), accountId: foreignKey('account_id'), - orgId: foreignKey('org_id').notNull(), invitedByOrgMemberId: foreignKey('invited_by_org_member_id'), status: mysqlEnum('status', ['invited', 'active', 'removed']).notNull(), role: mysqlEnum('role', ['member', 'admin']).notNull(), + personalSpaceId: foreignKey('personal_space_id'), orgMemberProfileId: foreignKey('org_member_profile_id').notNull(), addedAt: timestamp('added_at') .notNull() @@ -406,15 +407,20 @@ export const orgMembersRelations = relations(orgMembers, ({ one, many }) => ({ references: [orgMemberProfiles.id] }), routingRuleDestinations: many(emailRoutingRulesDestinations), - authorizedEmailIdentities: many(emailIdentitiesAuthorizedOrgMembers) + authorizedEmailIdentities: many(emailIdentitiesAuthorizedOrgMembers), + personalSpace: one(spaces, { + fields: [orgMembers.personalSpaceId], + references: [spaces.id] + }), + spaceMemberships: many(spaceMembers, { relationName: 'member' }) })); export const orgMemberProfiles = mysqlTable( 'org_member_profiles', { id: serial('id').primaryKey(), - publicId: publicId('orgMemberProfile', 'public_id').notNull(), orgId: foreignKey('org_id').notNull(), + publicId: publicId('orgMemberProfile', 'public_id').notNull(), avatarTimestamp: timestamp('avatar_timestamp'), accountId: foreignKey('account_id'), firstName: varchar('first_name', { length: 64 }), @@ -442,6 +448,10 @@ export const orgMemberProfileRelations = relations( org: one(orgs, { fields: [orgMemberProfiles.orgId], references: [orgs.id] + }), + orgMember: one(orgMembers, { + fields: [orgMemberProfiles.id], + references: [orgMembers.orgMemberProfileId] }) }) ); @@ -450,9 +460,9 @@ export const teams = mysqlTable( 'teams', { id: serial('id'), // -> removed pk from here + orgId: foreignKey('org_id').notNull(), publicId: publicId('teams', 'public_id').notNull(), avatarTimestamp: timestamp('avatar_timestamp'), - orgId: foreignKey('org_id').notNull(), name: varchar('name', { length: 128 }).notNull(), color: mysqlEnum('color', [...uiColors]), description: text('description'), @@ -475,7 +485,8 @@ export const teamsRelations = relations(teams, ({ one, many }) => ({ }), members: many(teamMembers), routingRuleDestinations: many(emailRoutingRulesDestinations), - authorizedEmailIdentities: many(emailIdentitiesAuthorizedOrgMembers) + authorizedEmailIdentities: many(emailIdentitiesAuthorizedOrgMembers), + spaceMemberships: many(spaceMembers) })); export const teamMembers = mysqlTable( @@ -523,15 +534,231 @@ export const teamMembersRelations = relations(teamMembers, ({ one }) => ({ }) })); +//******************* */ +//* Spaces tables + +export const spaces = mysqlTable( + 'spaces', + { + id: serial('id').primaryKey(), + orgId: foreignKey('org_id').notNull(), + parentSpaceId: foreignKey('parent_space_id'), + publicId: publicId('spaces', 'public_id').notNull(), + shortcode: varchar('shortcode', { length: 64 }).notNull(), + type: mysqlEnum('type', ['open', 'shared']).notNull(), + personalSpace: boolean('personal_space').notNull().default(false), + convoPrefix: varchar('convo_prefix', { length: 8 }), + inheritParentPermissions: boolean('inherit_parent_permissions') + .notNull() + .default(false), + name: varchar('name', { length: 128 }).notNull(), + icon: varchar('icon', { length: 32 }).notNull().default('squares-four'), + color: mysqlEnum('color', [...uiColors]).notNull(), + description: text('description'), + avatarTimestamp: timestamp('avatar_timestamp'), + createdByOrgMemberId: foreignKey('created_by_org_member_id').notNull(), + createdAt: timestamp('created_at') + .notNull() + .$defaultFn(() => new Date()) + }, + (table) => ({ + publicIdIndex: uniqueIndex('public_id_idx').on(table.publicId), + shortcodeIndex: uniqueIndex('shortcode_idx').on(table.shortcode), + orgIdIndex: index('org_id_idx').on(table.orgId) + }) +); + +export const spaceRelations = relations(spaces, ({ one, many }) => ({ + parentSpace: one(spaces, { + fields: [spaces.parentSpaceId], + references: [spaces.id], + relationName: 'parent' + }), + org: one(orgs, { + fields: [spaces.orgId], + references: [orgs.id] + }), + createdByOrgMember: one(orgMembers, { + fields: [spaces.createdByOrgMemberId], + references: [orgMembers.id] + }), + subSpaces: many(spaces, { relationName: 'parent' }), + members: many(spaceMembers), + statuses: many(spaceStatuses), + tags: many(spaceTags) +})); + +//* Space Members + +export const spaceMembers = mysqlTable( + 'space_members', + { + id: serial('id').primaryKey(), + orgId: foreignKey('org_id').notNull(), + publicId: publicId('spaceMembers', 'public_id').notNull(), + spaceId: foreignKey('space_id').notNull(), + orgMemberId: foreignKey('org_member_id'), + teamId: foreignKey('team_id'), + role: mysqlEnum('role', ['member', 'admin']).notNull().default('member'), + notifications: mysqlEnum('notifications', ['active', 'muted', 'off']) + .notNull() + .default('active'), + addedByOrgMemberId: foreignKey('added_by_org_member_id').notNull(), + addedAt: timestamp('added_at') + .notNull() + .$defaultFn(() => new Date()), + removedAt: timestamp('removed_at'), + canCreate: boolean('can_create').notNull().default(true), + canRead: boolean('can_read').notNull().default(true), + canComment: boolean('can_comment').notNull().default(true), + canReply: boolean('can_reply').notNull().default(true), + canDelete: boolean('can_delete').notNull().default(true), + canChangeStatus: boolean('can_change_status').notNull().default(true), + canSetStatusToClosed: boolean('can_set_status_to_closed') + .notNull() + .default(true), + canAddTags: boolean('can_add_tags').notNull().default(true), + canMoveToAnotherSpace: boolean('can_move_to_another_space') + .notNull() + .default(true), + canAddToAnotherSpace: boolean('can_add_to_another_space') + .notNull() + .default(true), + canMergeConvos: boolean('can_merge').notNull().default(true), + canAddParticipants: boolean('can_add_participants').notNull().default(true) + }, + (table) => ({ + orgIdIndex: index('org_id_idx').on(table.orgId), + spaceIdIndex: index('space_id_idx').on(table.spaceId), + orgMemberIdIndex: index('org_member_id_idx').on(table.orgMemberId) + }) +); + +export const spaceMemberRelations = relations(spaceMembers, ({ one }) => ({ + org: one(orgs, { + fields: [spaceMembers.orgId], + references: [orgs.id] + }), + space: one(spaces, { + fields: [spaceMembers.spaceId], + references: [spaces.id] + }), + team: one(teams, { + fields: [spaceMembers.teamId], + references: [teams.id] + }), + orgMember: one(orgMembers, { + fields: [spaceMembers.orgMemberId], + references: [orgMembers.id], + relationName: 'member' + }), + addedByOrgMember: one(orgMembers, { + fields: [spaceMembers.addedByOrgMemberId], + references: [orgMembers.id], + relationName: 'addedBy' + }) +})); + +export const spaceStatuses = mysqlTable( + 'space_statuses', + { + id: serial('id').primaryKey(), + orgId: foreignKey('org_id').notNull(), + publicId: publicId('spaceStatuses', 'public_id').notNull(), + spaceId: foreignKey('space_id').notNull(), + type: mysqlEnum('type', [ + 'not_started', + 'in_progress', + 'completed', + 'cancelled' + ]).notNull(), + order: tinyint('order', { unsigned: true }).notNull(), + name: varchar('name', { length: 32 }).notNull(), + color: mysqlEnum('color', [...uiColors]).notNull(), + icon: varchar('icon', { length: 32 }).notNull().default('check'), + description: text('description'), + disabled: boolean('disabled').notNull().default(false), + createdByOrgMemberId: foreignKey('created_by_org_member_id').notNull(), + createdAt: timestamp('created_at') + .notNull() + .$defaultFn(() => new Date()) + }, + (table) => ({ + publicIdIndex: uniqueIndex('public_id_idx').on(table.publicId), + orgIdIndex: index('org_id_idx').on(table.orgId), + spaceIdIndex: index('space_id_idx').on(table.spaceId) + }) +); + +export const spaceStatusesRelations = relations( + spaceStatuses, + ({ one, many }) => ({ + org: one(orgs, { + fields: [spaceStatuses.orgId], + references: [orgs.id] + }), + space: one(spaces, { + fields: [spaceStatuses.spaceId], + references: [spaces.id] + }), + createdByOrgMember: one(orgMembers, { + fields: [spaceStatuses.createdByOrgMemberId], + references: [orgMembers.id] + }), + convoStatuses: many(convoStatuses) + }) +); + +export const spaceTags = mysqlTable( + 'space_tags', + { + id: serial('id').primaryKey(), + orgId: foreignKey('org_id').notNull(), + publicId: publicId('spaceTags', 'public_id').notNull(), + spaceId: foreignKey('space_id').notNull(), + label: varchar('label', { length: 32 }).notNull(), + description: text('description'), + color: mysqlEnum('color', [...uiColors]).notNull(), + createdByOrgMemberId: foreignKey('created_by_org_member_id').notNull(), + createdAt: timestamp('created_at') + .notNull() + .$defaultFn(() => new Date()) + }, + (table) => ({ + publicIdIndex: uniqueIndex('public_id_idx').on(table.publicId), + orgIdIndex: index('org_id_idx').on(table.orgId), + spaceIdIndex: index('space_id_idx').on(table.spaceId), + createdByOrgMemberIdIndex: index('created_by_org_member_id_idx').on( + table.createdByOrgMemberId + ) + }) +); + +export const spaceTagsRelations = relations(spaceTags, ({ one, many }) => ({ + org: one(orgs, { + fields: [spaceTags.orgId], + references: [orgs.id] + }), + space: one(spaces, { + fields: [spaceTags.spaceId], + references: [spaces.id] + }), + createdByOrgMember: one(orgMembers, { + fields: [spaceTags.createdByOrgMemberId], + references: [orgMembers.id] + }), + convoTags: many(convoTags) +})); + //******************* */ //* Domains table export const domains = mysqlTable( 'domains', { id: serial('id').primaryKey(), - publicId: publicId('domains', 'public_id').notNull(), orgId: foreignKey('org_id').notNull(), disabled: boolean('disabled').notNull().default(false), + publicId: publicId('domains', 'public_id').notNull(), catchAllAddress: foreignKey('catch_all_address'), postalHost: varchar('postal_host', { length: 32 }).notNull(), domain: varchar('domain', { length: 256 }).notNull(), @@ -601,8 +828,8 @@ export const postalServers = mysqlTable( 'postal_servers', { id: serial('id').primaryKey(), - publicId: publicId('postalServers', 'public_id').notNull(), orgId: foreignKey('org_id').notNull(), + publicId: publicId('postalServers', 'public_id').notNull(), type: mysqlEnum('type', ['email', 'transactional', 'marketing']).notNull(), apiKey: varchar('api_key', { length: 64 }).notNull(), smtpKey: varchar('smtp_key', { length: 64 }), @@ -636,9 +863,9 @@ export const contacts = mysqlTable( 'contacts', { id: serial('id').primaryKey(), + orgId: foreignKey('org_id').notNull(), publicId: publicId('contacts', 'public_id').notNull(), avatarTimestamp: timestamp('avatar_timestamp'), - orgId: foreignKey('org_id').notNull(), reputationId: foreignKey('reputation_id').notNull(), name: varchar('name', { length: 128 }), setName: varchar('set_name', { length: 128 }), @@ -721,8 +948,8 @@ export const emailRoutingRules = mysqlTable( 'email_routing_rules', { id: serial('id').primaryKey(), - publicId: publicId('emailRoutingRules', 'public_id').notNull(), orgId: foreignKey('org_id').notNull(), + publicId: publicId('emailRoutingRules', 'public_id').notNull(), name: varchar('name', { length: 128 }).notNull(), description: text('description'), createdBy: foreignKey('created_by').notNull(), @@ -756,11 +983,13 @@ export const emailRoutingRulesDestinations = mysqlTable( 'email_routing_rules_destinations', { id: serial('id').primaryKey(), - publicId: publicId('emailRoutingRuleDestinations', 'public_id').notNull(), orgId: foreignKey('org_id').notNull(), + publicId: publicId('emailRoutingRuleDestinations', 'public_id').notNull(), ruleId: foreignKey('rule_id').notNull(), teamId: foreignKey('team_id'), orgMemberId: foreignKey('org_member_id'), + spaceId: foreignKey('space_id'), + assignToSpaceMemberId: foreignKey('assign_to_space_member_id'), createdAt: timestamp('created_at') .notNull() .$defaultFn(() => new Date()) @@ -771,7 +1000,7 @@ export const emailRoutingRulesDestinations = mysqlTable( ruleIdIndex: index('rule_id_idx').on(table.ruleId), teamIdIndex: index('team_id_idx').on(table.teamId), orgMemberIdIndex: index('org_member_id_idx').on(table.orgMemberId) - //TODO: add support for Check constraints when implemented in drizzle-orm & drizzle-kit : orgMemberId//teamId + //TODO: add support for Check constraints when implemented in drizzle-orm & drizzle-kit : orgMemberId//teamId//spaceId }) ); export const emailRoutingRulesDestinationsRelations = relations( @@ -792,6 +1021,14 @@ export const emailRoutingRulesDestinationsRelations = relations( orgMember: one(orgMembers, { fields: [emailRoutingRulesDestinations.orgMemberId], references: [orgMembers.id] + }), + space: one(spaces, { + fields: [emailRoutingRulesDestinations.spaceId], + references: [spaces.id] + }), + assignToSpaceMember: one(spaceMembers, { + fields: [emailRoutingRulesDestinations.assignToSpaceMemberId], + references: [spaceMembers.id] }) }) ); @@ -800,8 +1037,8 @@ export const emailIdentities = mysqlTable( 'email_identities', { id: serial('id').primaryKey(), - publicId: publicId('emailIdentities', 'public_id').notNull(), orgId: foreignKey('org_id').notNull(), + publicId: publicId('emailIdentities', 'public_id').notNull(), username: varchar('username', { length: 32 }).notNull(), domainName: varchar('domain_name', { length: 128 }).notNull(), domainId: foreignKey('domain_id'), @@ -861,6 +1098,7 @@ export const emailIdentitiesAuthorizedOrgMembers = mysqlTable( identityId: foreignKey('identity_id').notNull(), orgMemberId: foreignKey('org_member_id'), teamId: foreignKey('team_id'), + spaceId: foreignKey('space_id'), default: boolean('default').notNull().default(false), addedBy: foreignKey('added_by').notNull(), createdAt: timestamp('created_at') @@ -900,6 +1138,10 @@ export const emailIdentitiesAuthorizedOrgMemberRelations = relations( team: one(teams, { fields: [emailIdentitiesAuthorizedOrgMembers.teamId], references: [teams.id] + }), + space: one(spaces, { + fields: [emailIdentitiesAuthorizedOrgMembers.spaceId], + references: [spaces.id] }) }) ); @@ -908,9 +1150,9 @@ export const emailIdentitiesPersonal = mysqlTable( 'email_identities_personal', { id: serial('id').primaryKey(), + orgId: foreignKey('org_id').notNull(), publicId: publicId('emailIdentitiesPersonal', 'public_id').notNull(), accountId: foreignKey('account_id').notNull(), - orgId: foreignKey('org_id').notNull(), emailIdentityId: foreignKey('email_identity_id').notNull(), createdAt: timestamp('created_at') .notNull() @@ -948,8 +1190,8 @@ export const emailIdentityExternal = mysqlTable( 'email_identity_external', { id: serial('id').primaryKey(), - publicId: publicId('emailIdentitiesExternal', 'public_id').notNull(), orgId: foreignKey('org_id').notNull(), + publicId: publicId('emailIdentitiesExternal', 'public_id').notNull(), nickname: varchar('nickname', { length: 128 }).notNull(), createdBy: foreignKey('created_by').notNull(), username: varchar('username', { @@ -1017,7 +1259,155 @@ export const convosRelations = relations(convos, ({ one, many }) => ({ attachments: many(convoAttachments), entries: many(convoEntries), subjects: many(convoSubjects), - seen: many(convoSeenTimestamps) + seen: many(convoSeenTimestamps), + spaces: many(convoToSpaces), + statuses: many(convoStatuses), + tags: many(convoTags) +})); + +export const convoToSpaces = mysqlTable( + 'convo_to_spaces', + { + id: serial('id').primaryKey(), + orgId: foreignKey('org_id').notNull(), + publicId: publicId('convoToSpaces', 'public_id').notNull(), + convoId: foreignKey('convo_id').notNull(), + spaceId: foreignKey('space_id').notNull() + }, + (table) => ({ + orgIdIndex: index('org_id_idx').on(table.orgId), + publicIdIndex: uniqueIndex('public_id_idx').on(table.publicId), + convoIdIndex: index('convo_id_idx').on(table.convoId), + spaceIdIndex: index('space_id_idx').on(table.spaceId) + }) +); + +export const convoToSpacesRelations = relations( + convoToSpaces, + ({ one, many }) => ({ + org: one(orgs, { + fields: [convoToSpaces.orgId], + references: [orgs.id] + }), + convo: one(convos, { + fields: [convoToSpaces.convoId], + references: [convos.id] + }), + space: one(spaces, { + fields: [convoToSpaces.spaceId], + references: [spaces.id] + }), + statuses: many(convoStatuses), + tags: many(convoTags) + }) +); + +export const convoStatuses = mysqlTable( + 'convo_statuses', + { + id: serial('id').primaryKey(), + orgId: foreignKey('org_id').notNull(), + publicId: publicId('convoStatuses', 'public_id').notNull(), + convoId: foreignKey('convo_id').notNull(), + spaceId: foreignKey('space_id').notNull(), + convoToSpaceId: foreignKey('convo_to_space_id').notNull(), + status: foreignKey('status_id'), + byOrgMemberId: foreignKey('by_org_member_id').notNull(), + createdAt: timestamp('created_at') + .notNull() + .$defaultFn(() => new Date()) + }, + (table) => ({ + orgIdIndex: index('org_id_idx').on(table.orgId), + publicIdIndex: uniqueIndex('public_id_idx').on(table.publicId), + convoIdIndex: index('convo_id_idx').on(table.convoId), + spaceIdIndex: index('space_id_idx').on(table.spaceId), + convoToSpacesIdIndex: index('convo_to_spaces_id_idx').on( + table.convoToSpaceId + ), + statusIndex: index('status_idx').on(table.status) + }) +); + +export const convoStatusesRelations = relations(convoStatuses, ({ one }) => ({ + org: one(orgs, { + fields: [convoStatuses.orgId], + references: [orgs.id] + }), + convo: one(convos, { + fields: [convoStatuses.convoId], + references: [convos.id] + }), + space: one(spaces, { + fields: [convoStatuses.spaceId], + references: [spaces.id] + }), + convoToSpace: one(convoToSpaces, { + fields: [convoStatuses.convoToSpaceId], + references: [convoToSpaces.id] + }), + status: one(spaceStatuses, { + fields: [convoStatuses.status], + references: [spaceStatuses.id] + }), + byOrgMember: one(orgMembers, { + fields: [convoStatuses.byOrgMemberId], + references: [orgMembers.id] + }) +})); + +export const convoTags = mysqlTable( + 'convo_tags', + { + id: serial('id').primaryKey(), + orgId: foreignKey('org_id').notNull(), + publicId: publicId('convoTags', 'public_id').notNull(), + convoId: foreignKey('convo_id').notNull(), + spaceId: foreignKey('space_id').notNull(), + convoToSpaceId: foreignKey('convo_to_space_id').notNull(), + tagId: foreignKey('tag_id').notNull(), + addedByOrgMemberId: foreignKey('added_by_org_member_id').notNull(), + addedAt: timestamp('added_at') + .notNull() + .$defaultFn(() => new Date()) + }, + (table) => ({ + orgIdIndex: index('org_id_idx').on(table.orgId), + publicIdIndex: uniqueIndex('public_id_idx').on(table.publicId), + convoIdIndex: index('convo_id_idx').on(table.convoId), + spaceIdIndex: index('space_id_idx').on(table.spaceId), + convoToSpacesIdIndex: index('convo_to_spaces_id_idx').on( + table.convoToSpaceId + ), + tagIndex: index('tag_idx').on(table.tagId) + }) +); + +export const convoTagsRelations = relations(convoTags, ({ one }) => ({ + org: one(orgs, { + fields: [convoTags.orgId], + references: [orgs.id] + }), + convo: one(convos, { + fields: [convoTags.convoId], + references: [convos.id] + }), + space: one(spaces, { + fields: [convoTags.spaceId], + references: [spaces.id] + }), + convoToSpace: one(convoToSpaces, { + fields: [convoTags.convoToSpaceId], + references: [convoToSpaces.id] + }), + tag: one(spaceTags, { + fields: [convoTags.tagId], + references: [spaceTags.id] + }), + addedByOrgMember: one(orgMembers, { + fields: [convoTags.addedByOrgMemberId], + references: [orgMembers.id] + }) })); export const convoSubjects = mysqlTable( @@ -1220,8 +1610,8 @@ export const pendingAttachments = mysqlTable( 'pending_attachments', { id: serial('id').primaryKey(), - publicId: publicId('convoAttachments', 'public_id').notNull(), orgId: foreignKey('org_id').notNull(), + publicId: publicId('convoAttachments', 'public_id').notNull(), orgPublicId: publicId('org', 'org_public_id').notNull(), filename: varchar('filename', { length: 256 }).notNull(), createdAt: timestamp('created_at') diff --git a/packages/utils/typeid.ts b/packages/utils/typeid.ts index 0882548d..81bab607 100644 --- a/packages/utils/typeid.ts +++ b/packages/utils/typeid.ts @@ -8,27 +8,32 @@ export const idTypes = { account: 'a', accountSession: 'as', accountPasskey: 'ap', - org: 'o', - orgInvitations: 'oi', - orgMembers: 'om', - orgMemberProfile: 'omp', - groups: 'g', // remove after migration of groups to teams: April 2024 - groupMembers: 'gm', // remove after migration of groups to teams: April 2024 - teams: 't', - teamMembers: 'tm', - domains: 'dom', - postalServers: 'ps', contacts: 'k', + convos: 'c', + convoAttachments: 'ca', + convoEntries: 'ce', + convoParticipants: 'cp', + convoSubjects: 'cs', + convoToSpaces: 'c2s', + convoStatuses: 'css', + convoTags: 'cst', + domains: 'dom', emailRoutingRules: 'rr', emailRoutingRuleDestinations: 'rrd', emailIdentities: 'ei', emailIdentitiesPersonal: 'eip', emailIdentitiesExternal: 'eie', - convos: 'c', - convoSubjects: 'cs', - convoParticipants: 'cp', - convoAttachments: 'ca', - convoEntries: 'ce' + postalServers: 'ps', + teams: 't', + teamMembers: 'tm', + org: 'o', + orgInvitations: 'oi', + orgMembers: 'om', + orgMemberProfile: 'omp', + spaces: 's', + spaceMembers: 'sm', + spaceStatuses: 'ss', + spaceTags: 'st' } as const; type IdType = typeof idTypes;