Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,20 @@ export const OrganizationFormFields = ({
if (!acceptedTypes.includes(file.type)) {
const types = acceptedTypes.map((t) => t.split('/')[1]).join(', ');
toast.error({
message: t('That file type is not supported. Accepted types: {types}', { types }),
message: t(
'That file type is not supported. Accepted types: {types}',
{ types },
),
});
return;
}

if (file.size > DEFAULT_MAX_SIZE) {
const maxSizeMB = (DEFAULT_MAX_SIZE / 1024 / 1024).toFixed(2);
toast.error({
message: t('File too large. Maximum size: {maxSizeMB}MB', { maxSizeMB }),
message: t('File too large. Maximum size: {maxSizeMB}MB', {
maxSizeMB,
}),
});
return;
}
Expand All @@ -128,7 +133,6 @@ export const OrganizationFormFields = ({
<>
<div className="relative w-full pb-12 sm:pb-20">
<BannerUploader
className="relative aspect-[128/55] w-full bg-offWhite"
value={bannerImage?.url ?? undefined}
onChange={(file: File) =>
handleImageUpload(file, setBannerImage, uploadImage)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { DEFAULT_MAX_SIZE } from '@/hooks/useFileUpload';
import { getPublicUrl } from '@/utils';
import { analyzeError, useConnectionStatus } from '@/utils/connectionErrors';
import { trpc } from '@op/api/client';
import type { Organization } from '@op/api/encoders';
import { AvatarUploader } from '@op/ui/AvatarUploader';
Expand All @@ -17,7 +18,6 @@ import { forwardRef, useState } from 'react';
import { LuLink } from 'react-icons/lu';

import { useTranslations } from '@/lib/i18n';
import { analyzeError, useConnectionStatus } from '@/utils/connectionErrors';

import { GeoNamesMultiSelect } from '../../GeoNamesMultiSelect';
import { type ImageData } from '../../Onboarding/shared/OrganizationFormFields';
Expand Down Expand Up @@ -166,13 +166,13 @@ export const UpdateOrganizationForm = forwardRef<
slug: profile.profile.slug,
});
router.refresh();

onSuccess();
} catch (error) {
console.error('Failed to update organization:', error);

const errorInfo = analyzeError(error);

if (errorInfo.isConnectionError) {
toast.error({
title: 'Connection issue',
Expand Down Expand Up @@ -260,7 +260,6 @@ export const UpdateOrganizationForm = forwardRef<
{/* Header Images */}
<div className="relative w-full pb-12 sm:pb-20">
<BannerUploader
className="relative aspect-[128/55] w-full bg-offWhite"
value={bannerImage?.url ?? undefined}
onChange={(file: File) =>
handleImageUpload(file, setBannerImage, uploadImage)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { db, sql } from '@op/db/client';
import { locations } from '@op/db/schema';

export const getOrganizationsByProfile = async (profileId: string) => {
// Find all users who have access to this profile
// Either as their personal profile or as their current profile
const usersWithProfile = await db.query.users.findMany({
where: (table, { eq, or }) =>
or(
eq(table.profileId, profileId),
eq(table.currentProfileId, profileId),
),
with: {
organizationUsers: {
with: {
organization: {
with: {
projects: true,
links: true,
profile: {
with: {
headerImage: true,
avatarImage: true,
},
},
whereWeWork: {
with: {
location: {
extras: {
x: sql<number>`ST_X(${locations.location})`.as('x'),
y: sql<number>`ST_Y(${locations.location})`.as('y'),
},
columns: {
id: true,
name: true,
placeId: true,
countryCode: true,
countryName: true,
metadata: true,
latLng: false,
},
},
},
},
},
},
},
},
},
});

// Collect all unique organizations
const organizationMap = new Map();

for (const user of usersWithProfile) {
for (const orgUser of user.organizationUsers) {
if (orgUser.organization) {
const org = orgUser.organization;

// Transform whereWeWork to match expected format
const transformedOrg = {
...org,
whereWeWork: org.whereWeWork.map((item: any) => item.location),
};

organizationMap.set(org.id, transformedOrg);
}
}
}

return Array.from(organizationMap.values());
};
2 changes: 2 additions & 0 deletions packages/common/src/services/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ export * from './searchOrganizations';
export * from './matchingDomainOrganizations';
export * from './joinOrganization';
export * from './validators';
export * from './inviteUsers';
export * from './getOrganizationsByProfile';
144 changes: 144 additions & 0 deletions packages/common/src/services/organization/inviteUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { db } from '@op/db/client';
import { allowList } from '@op/db/schema';
import { sendInvitationEmail } from '../email';
import { OPURLConfig } from '@op/core';

export interface InviteUsersInput {
emails: string[];
role?: string;
organizationId?: string;
personalMessage?: string;
authUserId: string;
authUserEmail?: string;
}

export interface InviteResult {
success: boolean;
message: string;
details: {
successful: string[];
failed: { email: string; reason: string }[];
};
}

export const inviteUsersToOrganization = async (input: InviteUsersInput): Promise<InviteResult> => {
const { emails, role = 'Admin', organizationId, personalMessage, authUserId, authUserEmail } = input;

// Get the current user's database record with organization details
const authUser = await db.query.users.findFirst({
where: (table, { eq }) => eq(table.authUserId, authUserId),
with: {
currentOrganization: {
with: {
profile: true,
},
},
currentProfile: true,
},
});

// For new organization invites, we don't need the user to be in an organization
// For existing organization invites, we do need it
if (
(!authUser?.currentProfileId && !authUser?.lastOrgId) ||
(!authUser.currentOrganization && !authUser.currentProfile)
) {
throw new Error('User must be associated with an organization to send invites');
}

const currentProfile =
authUser.currentProfile ??
(authUser.currentOrganization as any)?.profile;

const results = {
successful: [] as string[],
failed: [] as { email: string; reason: string }[],
};

// Process each email
for (const rawEmail of emails) {
const email = rawEmail.toLowerCase();
try {
// Check if email is already in the allowList
const existingEntry = await db.query.allowList.findFirst({
where: (table, { eq }) => eq(table.email, email),
});

if (!existingEntry) {
// Determine metadata based on whether it's a new organization invite
const metadata = organizationId
? {
invitedBy: authUserId,
invitedAt: new Date().toISOString(),
inviteType: 'new_organization',
personalMessage: personalMessage,
inviterOrganizationName:
(currentProfile as any)?.profile?.name || 'Common',
}
: {
invitedBy: authUserId,
invitedAt: new Date().toISOString(),
personalMessage: personalMessage,
role,
};

// Add the email to the allowList
await db.insert(allowList).values({
email,
organizationId: organizationId ?? null,
metadata,
});
}

// Send invitation email
try {
await sendInvitationEmail({
to: email,
inviterName:
authUser?.name || authUserEmail || 'A team member',
organizationName: organizationId
? (authUser?.currentOrganization as any)?.profile?.name ||
'an organization'
: undefined,
inviteUrl: OPURLConfig('APP').ENV_URL,
message: personalMessage,
});
results.successful.push(email);
} catch (emailError) {
console.error(
`Failed to send invitation email to ${email}:`,
emailError,
);
// Email failed but database insertion succeeded
results.successful.push(email);
}
} catch (error) {
console.error(`Failed to process invitation for ${email}:`, error);
results.failed.push({
email,
reason: error instanceof Error ? error.message : 'Unknown error',
});
}
}

const totalEmails = emails.length;
const successCount = results.successful.length;

let message: string;
if (successCount === totalEmails) {
message = `All ${totalEmails} invitation${totalEmails > 1 ? 's' : ''} sent successfully`;
} else if (successCount > 0) {
message = `${successCount} of ${totalEmails} invitations sent successfully`;
} else {
message = 'No invitations were sent successfully';
}

return {
success: successCount > 0,
message,
details: {
successful: results.successful,
failed: results.failed,
},
};
};
98 changes: 98 additions & 0 deletions packages/common/src/services/posts/createPostInOrganization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { db } from '@op/db/client';
import { attachments, posts, postsToOrganizations } from '@op/db/schema';
import type { User } from '@supabase/supabase-js';
import { TRPCError } from '@trpc/server';

import { getOrgAccessUser } from '../';
import { UnauthorizedError } from '../../utils/error';

export interface CreatePostInOrganizationOptions {
id: string;
content: string;
attachmentIds?: string[];
user: User;
}

export const createPostInOrganization = async (
options: CreatePostInOrganizationOptions,
) => {
const { id, content, attachmentIds = [], user } = options;

const orgUser = await getOrgAccessUser({
organizationId: id,
user,
});

if (!orgUser) {
throw new UnauthorizedError();
}

try {
// Get all storage objects that were attached to the post
const allStorageObjects =
attachmentIds.length > 0
? await db.query.objectsInStorage.findMany({
where: (table, { inArray }) => inArray(table.id, attachmentIds),
})
: [];

const [post] = await db
.insert(posts)
.values({
content,
})
.returning();

if (!post) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to add post to organization',
});
}

// Create the join record associating the post with the organization
const queryPromises: Promise<any>[] = [
db.insert(postsToOrganizations).values({
organizationId: id,
postId: post.id,
}),
];

// Create attachment records if any attachments were uploaded
if (allStorageObjects.length > 0) {
const attachmentValues = allStorageObjects.map((storageObject) => ({
postId: post.id,
storageObjectId: storageObject.id,
uploadedBy: orgUser.id,
fileName:
storageObject?.name
?.split('/')
.slice(-1)[0]
?.split('_')
.slice(1)
.join('_') ?? '',
mimeType: (storageObject.metadata as { mimetype: string }).mimetype,
}));

queryPromises.push(db.insert(attachments).values(attachmentValues));
}

// Run attachments and join record in parallel
await Promise.all(queryPromises);

return {
result: {
...post,
reactionCounts: {},
userReactions: [],
},
allStorageObjects,
};
} catch (error) {
console.log('ERROR', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Something went wrong when adding post to organization',
});
}
};
Loading
Loading