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 (
+
+ );
+}
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;