diff --git a/apps/app/src/components/Onboarding/shared/OrganizationFormFields.tsx b/apps/app/src/components/Onboarding/shared/OrganizationFormFields.tsx index 64ea1dc09..2693fbb21 100644 --- a/apps/app/src/components/Onboarding/shared/OrganizationFormFields.tsx +++ b/apps/app/src/components/Onboarding/shared/OrganizationFormFields.tsx @@ -94,7 +94,10 @@ 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; } @@ -102,7 +105,9 @@ export const OrganizationFormFields = ({ 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; } @@ -128,7 +133,6 @@ export const OrganizationFormFields = ({ <>
handleImageUpload(file, setBannerImage, uploadImage) diff --git a/apps/app/src/components/Profile/ProfileDetails/UpdateOrganizationForm.tsx b/apps/app/src/components/Profile/ProfileDetails/UpdateOrganizationForm.tsx index 544153c23..dd11e078f 100644 --- a/apps/app/src/components/Profile/ProfileDetails/UpdateOrganizationForm.tsx +++ b/apps/app/src/components/Profile/ProfileDetails/UpdateOrganizationForm.tsx @@ -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'; @@ -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'; @@ -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', @@ -260,7 +260,6 @@ export const UpdateOrganizationForm = forwardRef< {/* Header Images */}
handleImageUpload(file, setBannerImage, uploadImage) diff --git a/packages/common/src/services/organization/getOrganizationsByProfile.ts b/packages/common/src/services/organization/getOrganizationsByProfile.ts new file mode 100644 index 000000000..69dc2c168 --- /dev/null +++ b/packages/common/src/services/organization/getOrganizationsByProfile.ts @@ -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`ST_X(${locations.location})`.as('x'), + y: sql`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()); +}; \ No newline at end of file diff --git a/packages/common/src/services/organization/index.ts b/packages/common/src/services/organization/index.ts index dfeade4dd..a6b1aa552 100644 --- a/packages/common/src/services/organization/index.ts +++ b/packages/common/src/services/organization/index.ts @@ -9,3 +9,5 @@ export * from './searchOrganizations'; export * from './matchingDomainOrganizations'; export * from './joinOrganization'; export * from './validators'; +export * from './inviteUsers'; +export * from './getOrganizationsByProfile'; diff --git a/packages/common/src/services/organization/inviteUsers.ts b/packages/common/src/services/organization/inviteUsers.ts new file mode 100644 index 000000000..82a732c58 --- /dev/null +++ b/packages/common/src/services/organization/inviteUsers.ts @@ -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 => { + 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, + }, + }; +}; \ No newline at end of file diff --git a/packages/common/src/services/posts/createPostInOrganization.ts b/packages/common/src/services/posts/createPostInOrganization.ts new file mode 100644 index 000000000..fac095049 --- /dev/null +++ b/packages/common/src/services/posts/createPostInOrganization.ts @@ -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[] = [ + 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', + }); + } +}; diff --git a/packages/common/src/services/posts/deletePost.ts b/packages/common/src/services/posts/deletePost.ts new file mode 100644 index 000000000..3bac691a7 --- /dev/null +++ b/packages/common/src/services/posts/deletePost.ts @@ -0,0 +1,34 @@ +import { db, eq, and } from '@op/db/client'; +import { posts, postsToOrganizations } from '@op/db/schema'; + +export interface DeletePostByIdOptions { + postId: string; + organizationId: string; +} + +export const deletePostById = async (options: DeletePostByIdOptions) => { + const { postId, organizationId } = options; + // Verify the post exists and belongs to the organization + const postExists = await db + .select() + .from(posts) + .innerJoin( + postsToOrganizations, + eq(posts.id, postsToOrganizations.postId), + ) + .where( + and( + eq(posts.id, postId), + eq(postsToOrganizations.organizationId, organizationId), + ), + ) + .limit(1); + + if (!postExists.length) { + throw new Error('Post not found or does not belong to the specified organization'); + } + + await db.delete(posts).where(eq(posts.id, postId)); + + return { success: true }; +}; \ No newline at end of file diff --git a/packages/common/src/services/posts/index.ts b/packages/common/src/services/posts/index.ts index c7efef45a..4024d47d0 100644 --- a/packages/common/src/services/posts/index.ts +++ b/packages/common/src/services/posts/index.ts @@ -1,3 +1,6 @@ export * from './listPosts'; export * from './getPosts'; export * from './createPost'; +export * from './deletePost'; +export * from './listRelatedOrganizationPosts'; +export * from './createPostInOrganization'; diff --git a/packages/common/src/services/posts/listRelatedOrganizationPosts.ts b/packages/common/src/services/posts/listRelatedOrganizationPosts.ts new file mode 100644 index 000000000..f33484630 --- /dev/null +++ b/packages/common/src/services/posts/listRelatedOrganizationPosts.ts @@ -0,0 +1,135 @@ +import { + getCurrentProfileId, + getItemsWithReactionsAndComments, + getRelatedOrganizations, +} from '../'; +import { decodeCursor, encodeCursor } from '../../utils'; +import { and, eq, inArray, lt, or, db } from '@op/db/client'; +import { postsToOrganizations } from '@op/db/schema'; +import type { User } from '@supabase/supabase-js'; + +export interface ListAllPostsOptions { + limit?: number; + cursor?: string | null; +} + +export interface ListRelatedPostsOptions { + organizationId: string; + user: User; +} + +export const listAllRelatedOrganizationPosts = async (options: ListAllPostsOptions = {}) => { + const { limit = 200, cursor } = options; + + // Parse cursor + const cursorData = cursor ? decodeCursor(cursor) : null; + + // Build cursor condition for pagination + const cursorCondition = cursorData + ? or( + lt(postsToOrganizations.createdAt, cursorData.createdAt), + and( + eq(postsToOrganizations.createdAt, cursorData.createdAt), + lt(postsToOrganizations.postId, cursorData.id), + ), + ) + : undefined; + + // Fetch posts for all organizations with pagination + const [result, profileId] = await Promise.all([ + db.query.postsToOrganizations.findMany({ + where: cursorCondition, + with: { + post: { + where: (table, { isNull }) => isNull(table.parentPostId), // Only show top-level posts + with: { + attachments: { + with: { + storageObject: true, + }, + }, + reactions: true, + }, + }, + organization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + }, + orderBy: (table, { desc }) => desc(table.createdAt), + limit: limit + 1, // Fetch one extra to check hasMore + }), + getCurrentProfileId(), + ]); + + // Filter out any items where post is null (due to parentPostId filtering) + const filteredResult = result.filter((item) => item.post !== null); + + const hasMore = filteredResult.length > limit; + const items = hasMore ? filteredResult.slice(0, limit) : filteredResult; + const lastItem = items[items.length - 1]; + const nextCursor = + hasMore && lastItem && lastItem.createdAt + ? encodeCursor(new Date(lastItem.createdAt), lastItem.postId) + : null; + + const itemsWithReactionsAndComments = + await getItemsWithReactionsAndComments({ items, profileId }); + + return { + items: itemsWithReactionsAndComments, + next: nextCursor, + hasMore, + }; +}; + +export const listRelatedOrganizationPosts = async (options: ListRelatedPostsOptions) => { + const { organizationId, user } = options; + + // Get related organizations + const { records: organizations } = await getRelatedOrganizations({ + user, + orgId: organizationId, + pending: false, + }); + + const orgIds = organizations?.map((org: any) => org.id) ?? []; + orgIds.push(organizationId); // Add our own org so we see our own posts + + // Fetch posts for all related organizations + const result = await db.query.postsToOrganizations.findMany({ + where: () => inArray(postsToOrganizations.organizationId, orgIds), + with: { + post: { + where: (table, { isNull }) => isNull(table.parentPostId), // Only show top-level posts + with: { + attachments: { + with: { + storageObject: true, + }, + }, + }, + }, + organization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + }, + orderBy: (table, { desc }) => desc(table.createdAt), + }); + + // Filter out any items where post is null (due to parentPostId filtering) + const filteredResult = result.filter((item) => item.post !== null); + + return filteredResult; +}; diff --git a/packages/common/src/services/reactions/addReaction.ts b/packages/common/src/services/reactions/addReaction.ts new file mode 100644 index 000000000..18942ae6c --- /dev/null +++ b/packages/common/src/services/reactions/addReaction.ts @@ -0,0 +1,30 @@ +import { and, db, eq } from '@op/db/client'; +import { postReactions } from '@op/db/schema'; + +export interface AddReactionOptions { + postId: string; + profileId: string; + reactionType: string; +} + +export const addReaction = async (options: AddReactionOptions) => { + const { postId, profileId, reactionType } = options; + await db.transaction(async (tx) => { + // First, remove any existing reaction from this user on this post + await tx + .delete(postReactions) + .where( + and( + eq(postReactions.postId, postId), + eq(postReactions.profileId, profileId), + ), + ); + + // Then add the new reaction + await tx.insert(postReactions).values({ + postId, + profileId, + reactionType, + }); + }); +}; diff --git a/packages/common/src/services/reactions/getExistingReaction.ts b/packages/common/src/services/reactions/getExistingReaction.ts new file mode 100644 index 000000000..2657cb634 --- /dev/null +++ b/packages/common/src/services/reactions/getExistingReaction.ts @@ -0,0 +1,23 @@ +import { and, db, eq } from '@op/db/client'; +import { postReactions } from '@op/db/schema'; + +export interface GetExistingReactionOptions { + postId: string; + profileId: string; +} + +export const getExistingReaction = async (options: GetExistingReactionOptions) => { + const { postId, profileId } = options; + const existingReaction = await db + .select() + .from(postReactions) + .where( + and( + eq(postReactions.postId, postId), + eq(postReactions.profileId, profileId), + ), + ) + .limit(1); + + return existingReaction[0] || null; +}; diff --git a/packages/common/src/services/reactions/index.ts b/packages/common/src/services/reactions/index.ts index 37cdcb547..7fb48b20c 100644 --- a/packages/common/src/services/reactions/index.ts +++ b/packages/common/src/services/reactions/index.ts @@ -1 +1,5 @@ -export * from './validateReaction'; \ No newline at end of file +export * from './validateReaction'; +export * from './addReaction'; +export * from './removeReaction'; +export * from './getExistingReaction'; +export * from './toggleReaction'; diff --git a/packages/common/src/services/reactions/removeReaction.ts b/packages/common/src/services/reactions/removeReaction.ts new file mode 100644 index 000000000..5ac677f31 --- /dev/null +++ b/packages/common/src/services/reactions/removeReaction.ts @@ -0,0 +1,19 @@ +import { and, db, eq } from '@op/db/client'; +import { postReactions } from '@op/db/schema'; + +export interface RemoveReactionOptions { + postId: string; + profileId: string; +} + +export const removeReaction = async (options: RemoveReactionOptions) => { + const { postId, profileId } = options; + await db + .delete(postReactions) + .where( + and( + eq(postReactions.postId, postId), + eq(postReactions.profileId, profileId), + ), + ); +}; diff --git a/packages/common/src/services/reactions/toggleReaction.ts b/packages/common/src/services/reactions/toggleReaction.ts new file mode 100644 index 000000000..69a99ca9b --- /dev/null +++ b/packages/common/src/services/reactions/toggleReaction.ts @@ -0,0 +1,31 @@ +import { addReaction } from './addReaction'; +import { getExistingReaction } from './getExistingReaction'; +import { removeReaction } from './removeReaction'; + +export interface ToggleReactionOptions { + postId: string; + profileId: string; + reactionType: string; +} + +export const toggleReaction = async (options: ToggleReactionOptions) => { + const { postId, profileId, reactionType } = options; + + const existingReaction = await getExistingReaction({ postId, profileId }); + + if (existingReaction) { + // If user has the same reaction type, remove it + if (existingReaction.reactionType === reactionType) { + await removeReaction({ postId, profileId }); + return { success: true, action: 'removed' as const }; + } else { + // If user has a different reaction type, replace it + await addReaction({ postId, profileId, reactionType }); + return { success: true, action: 'replaced' as const }; + } + } else { + // No existing reaction, add new one + await addReaction({ postId, profileId, reactionType }); + return { success: true, action: 'added' as const }; + } +}; diff --git a/packages/common/src/services/reactions/validateReaction.ts b/packages/common/src/services/reactions/validateReaction.ts index e58cf6e96..61d2f1ef9 100644 --- a/packages/common/src/services/reactions/validateReaction.ts +++ b/packages/common/src/services/reactions/validateReaction.ts @@ -36,4 +36,4 @@ export function validateReactionEmoji(emoji: string): ReactionValidationResult { return { isValid: true, }; -} \ No newline at end of file +} diff --git a/packages/common/src/services/user/index.ts b/packages/common/src/services/user/index.ts index cf4ce3645..3875cfd91 100644 --- a/packages/common/src/services/user/index.ts +++ b/packages/common/src/services/user/index.ts @@ -1,5 +1,5 @@ -import { db, eq } from '@op/db/client'; -import { allowList, users } from '@op/db/schema'; +import { db, eq, sql, and } from '@op/db/client'; +import { allowList, users, organizationUsers, usersUsedStorage, organizations } from '@op/db/schema'; export interface User { id: number; @@ -58,3 +58,235 @@ export const getAllowListUser = async ({ email }: { email?: string }) => { return allowedEmail; }; + +export const getUserByAuthId = async ({ authUserId }: { authUserId: string }) => { + return await db.query.users.findFirst({ + where: (table, { eq }) => eq(table.authUserId, authUserId), + with: { + avatarImage: true, + organizationUsers: { + with: { + organization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + }, + }, + currentOrganization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + currentProfile: { + with: { + avatarImage: true, + headerImage: true, + }, + }, + profile: { + with: { + avatarImage: true, + headerImage: true, + }, + }, + }, + }); +}; + +export const createUserByAuthId = async ({ authUserId, email }: { authUserId: string; email: string }) => { + const [newUser] = await db + .insert(users) + .values({ + authUserId, + email, + }) + .returning(); + + if (!newUser) { + throw new Error('Could not create user'); + } + + return await db.query.users.findFirst({ + where: (table, { eq }) => eq(table.id, newUser.id), + with: { + avatarImage: true, + organizationUsers: { + with: { + organization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + }, + }, + currentOrganization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + currentProfile: { + with: { + avatarImage: true, + headerImage: true, + }, + }, + profile: { + with: { + avatarImage: true, + headerImage: true, + }, + }, + }, + }); +}; + +export const getUserWithProfiles = async ({ authUserId }: { authUserId: string }) => { + return await db.query.users.findFirst({ + where: (table, { eq }) => eq(table.authUserId, authUserId), + with: { + profile: { + with: { + avatarImage: true, + }, + }, + organizationUsers: { + with: { + organization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + }, + }, + }, + }); +}; + +export const getUserForProfileSwitch = async ({ authUserId }: { authUserId: string }) => { + return await db.query.users.findFirst({ + where: (table, { eq }) => eq(table.authUserId, authUserId), + with: { + profile: true, + organizationUsers: { + with: { + organization: { + with: { + profile: true, + }, + }, + }, + }, + }, + }); +}; + +export interface UpdateUserCurrentProfileOptions { + authUserId: string; + profileId: string; + orgId?: number; +} + +export const updateUserCurrentProfile = async (options: UpdateUserCurrentProfileOptions) => { + const { authUserId, profileId, orgId } = options; + return await db + .update(users) + .set({ + currentProfileId: profileId, + ...(orgId ? { lastOrgId: orgId.toString() } : {}), + }) + .where(eq(users.authUserId, authUserId)) + .returning(); +}; + +export const checkUsernameAvailability = async ({ username }: { username: string }) => { + if (username === '') { + return { available: true }; + } + + const result = await db + .select({ + exists: sql`true`, + }) + .from(organizationUsers) + .where(eq(users.username, username)) + .limit(1); + + if (!result.length || !result[0]) { + return { available: true }; + } + + return { available: false }; +}; + +export const getUserStorageUsage = async ({ userId }: { userId: string }) => { + const result = await db + .select() + .from(usersUsedStorage) + .where(and(eq(usersUsedStorage.userId, userId))) + .limit(1); + + if (!result.length || !result[0]) { + return { + usedStorage: 0, + maxStorage: 4000000000 as const, + }; + } + + return { + usedStorage: Number.parseInt(result[0].totalSize as string), + maxStorage: 4000000000 as const, + }; +}; + +export interface SwitchUserOrganizationOptions { + authUserId: string; + organizationId: string; +} + +export const switchUserOrganization = async (options: SwitchUserOrganizationOptions) => { + const { authUserId, organizationId } = options; + // First, get the organization to find its profile ID + const organization = await db.query.organizations.findFirst({ + where: eq(organizations.id, organizationId), + }); + + if (!organization) { + throw new Error('Organization not found'); + } + + const result = await db + .update(users) + .set({ + lastOrgId: organization.id, + currentProfileId: organization.profileId, + }) + .where(eq(users.authUserId, authUserId)) + .returning(); + + if (!result.length || !result[0]) { + throw new Error('User not found'); + } + + return result[0]; +}; \ No newline at end of file diff --git a/packages/ui/tailwind.shared.ts b/packages/ui/tailwind.shared.ts index 27f2f00ff..c0d5fad2b 100644 --- a/packages/ui/tailwind.shared.ts +++ b/packages/ui/tailwind.shared.ts @@ -140,38 +140,6 @@ const config: Omit = { colors: { accent: commonColors, border: 'hsl(var(--op-offWhite))', - // input: 'hsl(var(--input))', - // ring: 'hsl(var(--ring))', - // background: 'hsl(var(--background))', - // foreground: 'hsl(var(--foreground))', - // primary: { - // DEFAULT: 'hsl(var(--primary))', - // foreground: 'hsl(var(--primary-foreground))', - // }, - // secondary: { - // DEFAULT: 'hsl(var(--secondary))', - // foreground: 'hsl(var(--secondary-foreground))', - // }, - // destructive: { - // DEFAULT: 'hsl(var(--destructive))', - // foreground: 'hsl(var(--destructive-foreground))', - // }, - // muted: { - // DEFAULT: 'hsl(var(--muted))', - // foreground: 'hsl(var(--muted-foreground))', - // }, - // accent: { - // DEFAULT: 'hsl(var(--accent))', - // foreground: 'hsl(var(--accent-foreground))', - // }, - // popover: { - // DEFAULT: 'hsl(var(--popover))', - // foreground: 'hsl(var(--popover-foreground))', - // }, - // card: { - // DEFAULT: 'hsl(var(--card))', - // foreground: 'hsl(var(--card-foreground))', - // }, teal: { DEFAULT: 'hsl(var(--op-teal-500))', 50: 'hsl(var(--op-teal-50))', diff --git a/services/api/src/routers/account/getMyAccount.ts b/services/api/src/routers/account/getMyAccount.ts index c3d19aa3e..ab716921a 100644 --- a/services/api/src/routers/account/getMyAccount.ts +++ b/services/api/src/routers/account/getMyAccount.ts @@ -1,11 +1,9 @@ -import { CommonError, NotFoundError } from '@op/common'; -import { users } from '@op/db/schema'; +import { CommonError, NotFoundError, getUserByAuthId, createUserByAuthId } from '@op/common'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; import { userEncoder } from '../../encoders'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -25,55 +23,14 @@ export const getMyAccount = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router .meta(meta) .input(z.undefined()) .output(userEncoder) .query(async ({ ctx }) => { - const { db } = ctx.database; const { id, email } = ctx.user; - const result = await db.query.users.findFirst({ - where: (table, { eq }) => eq(table.authUserId, id), - with: { - avatarImage: true, - organizationUsers: { - with: { - organization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - }, - }, - currentOrganization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - currentProfile: { - with: { - avatarImage: true, - headerImage: true, - }, - }, - profile: { - with: { - avatarImage: true, - headerImage: true, - }, - }, - }, - }); + const result = await getUserByAuthId({ authUserId: id }); if (!result) { if (!email) { @@ -81,59 +38,15 @@ export const getMyAccount = router({ } // if there is no user but the user is authenticated, create one - const [newUser] = await db - .insert(users) - .values({ - authUserId: id, - email: ctx.user.email!, - }) - .returning(); + const newUserWithRelations = await createUserByAuthId({ + authUserId: id, + email: ctx.user.email!, + }); - if (!newUser) { + if (!newUserWithRelations) { throw new CommonError('Could not create user'); } - const newUserWithRelations = await db.query.users.findFirst({ - where: (table, { eq }) => eq(table.id, newUser.id), - with: { - avatarImage: true, - organizationUsers: { - with: { - organization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - }, - }, - currentOrganization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - currentProfile: { - with: { - avatarImage: true, - headerImage: true, - }, - }, - profile: { - with: { - avatarImage: true, - headerImage: true, - }, - }, - }, - }); - return userEncoder.parse(newUserWithRelations); } diff --git a/services/api/src/routers/account/getUserProfiles.ts b/services/api/src/routers/account/getUserProfiles.ts index 2537122f9..0934c2c8f 100644 --- a/services/api/src/routers/account/getUserProfiles.ts +++ b/services/api/src/routers/account/getUserProfiles.ts @@ -1,10 +1,9 @@ -import { UnauthorizedError } from '@op/common'; +import { UnauthorizedError, getUserWithProfiles } from '@op/common'; import { EntityType, ObjectsInStorage, Profile } from '@op/db/schema'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -37,7 +36,6 @@ export const getUserProfiles = router({ getUserProfiles: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) .meta(meta) .input(z.undefined()) .output( @@ -53,33 +51,10 @@ export const getUserProfiles = router({ ), ) .query(async ({ ctx }) => { - const { db } = ctx.database; const { id: authUserId } = ctx.user; // Get the user's database record - const user = await db.query.users.findFirst({ - where: (table, { eq }) => eq(table.authUserId, authUserId), - with: { - profile: { - with: { - avatarImage: true, - }, - }, - organizationUsers: { - with: { - organization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - }, - }, - }, - }); + const user = await getUserWithProfiles({ authUserId }); if (!user) { throw new UnauthorizedError('User not found'); diff --git a/services/api/src/routers/account/switchProfile.ts b/services/api/src/routers/account/switchProfile.ts index 2a2065058..50bda6f32 100644 --- a/services/api/src/routers/account/switchProfile.ts +++ b/services/api/src/routers/account/switchProfile.ts @@ -1,12 +1,10 @@ -import { users } from '@op/db/schema'; +import { getUserForProfileSwitch, updateUserCurrentProfile } from '@op/common'; import { TRPCError } from '@trpc/server'; -import { eq } from 'drizzle-orm'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; import { userEncoder } from '../../encoders'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -27,30 +25,14 @@ export const switchProfile = router({ switchProfile: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) .meta(meta) .input(z.object({ profileId: z.string().uuid() })) .output(userEncoder) .mutation(async ({ input, ctx }) => { - const { db } = ctx.database; const { id } = ctx.user; // Verify the profile exists and the user has access to it - const user = await db.query.users.findFirst({ - where: eq(users.authUserId, id), - with: { - profile: true, - organizationUsers: { - with: { - organization: { - with: { - profile: true, - }, - }, - }, - }, - }, - }); + const user = await getUserForProfileSwitch({ authUserId: id }); if (!user) { throw new TRPCError({ @@ -81,14 +63,7 @@ export const switchProfile = router({ let result; try { - result = await db - .update(users) - .set({ - currentProfileId: input.profileId, - ...(org ? { lastOrgId: org.organization?.id } : {}), - }) - .where(eq(users.authUserId, id)) - .returning(); + result = await updateUserCurrentProfile({ authUserId: id, profileId: input.profileId, orgId: org?.organization?.id }); } catch (error) { console.error(error); throw new TRPCError({ diff --git a/services/api/src/routers/account/updateLastOrgId.ts b/services/api/src/routers/account/updateLastOrgId.ts index 647a418bc..c2cc3e18d 100644 --- a/services/api/src/routers/account/updateLastOrgId.ts +++ b/services/api/src/routers/account/updateLastOrgId.ts @@ -1,12 +1,10 @@ -import { organizations, users } from '@op/db/schema'; +import { switchUserOrganization } from '@op/common'; import { TRPCError } from '@trpc/server'; -import { eq } from 'drizzle-orm'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; import { userEncoder } from '../../encoders'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -27,51 +25,37 @@ export const switchOrganization = router({ switchOrganization: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 5 })) .use(withAuthenticated) - .use(withDB) .meta(meta) .input(z.object({ organizationId: z.string().min(1) })) .output(userEncoder) .mutation(async ({ input, ctx }) => { - const { db } = ctx.database; const { id } = ctx.user; - let result; try { - // First, get the organization to find its profile ID - const organization = await db.query.organizations.findFirst({ - where: eq(organizations.id, input.organizationId), - }); - - if (!organization) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Organization not found', - }); - } - - result = await db - .update(users) - .set({ - lastOrgId: organization.id, - currentProfileId: organization.profileId, - }) - .where(eq(users.authUserId, id)) - .returning(); + const result = await switchUserOrganization({ authUserId: id, organizationId: input.organizationId }); + return userEncoder.parse(result); } catch (error) { console.error(error); + + if (error instanceof Error) { + if (error.message === 'Organization not found') { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Organization not found', + }); + } + if (error.message === 'User not found') { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'User not found', + }); + } + } + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to update currentProfileId', }); } - - if (!result.length || !result[0]) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'User not found', - }); - } - - return userEncoder.parse(result[0]); }), }); diff --git a/services/api/src/routers/account/updateUserProfile.ts b/services/api/src/routers/account/updateUserProfile.ts index 5b67d57f7..c78036cd2 100644 --- a/services/api/src/routers/account/updateUserProfile.ts +++ b/services/api/src/routers/account/updateUserProfile.ts @@ -5,7 +5,6 @@ import { ZodError, z } from 'zod'; import { userEncoder } from '../../encoders'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -27,7 +26,6 @@ const updateUserProfile = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 3 })) .use(withAuthenticated) - .use(withDB) // Router .meta(meta) .input( @@ -61,14 +59,12 @@ const updateUserProfile = router({ ) .output(userEncoder) .mutation(async ({ input, ctx }) => { - const { db } = ctx.database; const { user } = ctx; try { const result = await updateUserProfileService({ input, user, - db, }); return userEncoder.parse(result); diff --git a/services/api/src/routers/account/usedStorage.ts b/services/api/src/routers/account/usedStorage.ts index 807b1073e..76f2d5767 100644 --- a/services/api/src/routers/account/usedStorage.ts +++ b/services/api/src/routers/account/usedStorage.ts @@ -1,10 +1,8 @@ -import { usersUsedStorage } from '@op/db/schema'; -import { and, eq } from 'drizzle-orm'; +import { getUserStorageUsage } from '@op/common'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -26,7 +24,6 @@ const usedStorage = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router .meta(meta) .input(z.undefined()) @@ -37,25 +34,7 @@ const usedStorage = router({ }), ) .query(async ({ ctx }) => { - const { db } = ctx.database; - - const result = await db - .select() - .from(usersUsedStorage) - .where(and(eq(usersUsedStorage.userId, ctx.user.id))) - .limit(1); - - if (!result.length || !result[0]) { - return { - usedStorage: 0, - maxStorage: 4000000000, - }; - } - - return { - usedStorage: Number.parseInt(result[0].totalSize as string), - maxStorage: 4000000000, - }; + return await getUserStorageUsage({ userId: ctx.user.id }); }), }); diff --git a/services/api/src/routers/account/usernameAvailable.ts b/services/api/src/routers/account/usernameAvailable.ts index a23e9aebf..32f274f27 100644 --- a/services/api/src/routers/account/usernameAvailable.ts +++ b/services/api/src/routers/account/usernameAvailable.ts @@ -1,10 +1,8 @@ -import { organizationUsers, users } from '@op/db/schema'; -import { eq, sql } from 'drizzle-orm'; +import { checkUsernameAvailability } from '@op/common'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -26,7 +24,6 @@ const usernameAvailable = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router .meta(meta) .input( @@ -42,33 +39,9 @@ const usernameAvailable = router({ available: z.boolean(), }), ) - .query(async ({ input, ctx }) => { - const { db } = ctx.database; + .query(async ({ input }) => { const { username } = input; - - if (username === '') { - return { - available: true, - }; - } - - const result = await db - .select({ - exists: sql`true`, - }) - .from(organizationUsers) - .where(eq(users.username, username)) - .limit(1); - - if (!result.length || !result[0]) { - return { - available: true, - }; - } - - return { - available: false, - }; + return await checkUsernameAvailability({ username }); }), }); diff --git a/services/api/src/routers/individual/getIndividual.ts b/services/api/src/routers/individual/getIndividual.ts index 51b883b89..420a68403 100644 --- a/services/api/src/routers/individual/getIndividual.ts +++ b/services/api/src/routers/individual/getIndividual.ts @@ -9,7 +9,6 @@ import { z } from 'zod'; import { individualsTermsEncoder } from '../../encoders/individuals'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -29,7 +28,6 @@ export const getIndividualRouter = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router .meta(meta) .input(z.object({ id: z.string(), termUri: z.string().optional() })) @@ -71,7 +69,6 @@ export const getIndividualRouter = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router .input(z.object({ profileId: z.string(), termUri: z.string().optional() })) .output(individualsTermsEncoder) @@ -101,4 +98,4 @@ export const getIndividualRouter = router({ }); } }), -}); \ No newline at end of file +}); diff --git a/services/api/src/routers/organization/createOrganization.ts b/services/api/src/routers/organization/createOrganization.ts index b6eed9902..a1d4e7745 100644 --- a/services/api/src/routers/organization/createOrganization.ts +++ b/services/api/src/routers/organization/createOrganization.ts @@ -4,7 +4,6 @@ import type { OpenApiMeta } from 'trpc-to-openapi'; import { organizationsEncoder } from '../../encoders/organizations'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; import { createOrganizationInputSchema } from './validators'; @@ -25,7 +24,6 @@ export const createOrganizationRouter = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router .meta(meta) .input(createOrganizationInputSchema) diff --git a/services/api/src/routers/organization/createPostInOrganization.ts b/services/api/src/routers/organization/createPostInOrganization.ts index 8b193e8e6..38b411693 100644 --- a/services/api/src/routers/organization/createPostInOrganization.ts +++ b/services/api/src/routers/organization/createPostInOrganization.ts @@ -1,14 +1,11 @@ import { trackUserPost } from '@op/analytics'; -import { UnauthorizedError, getOrgAccessUser } from '@op/common'; -import { attachments, posts, postsToOrganizations } from '@op/db/schema'; -import { TRPCError } from '@trpc/server'; +import { createPostInOrganization } from '@op/common'; import { waitUntil } from '@vercel/functions'; // import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; import { postsEncoder } from '../../encoders'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -25,12 +22,11 @@ import { loggedProcedure, router } from '../../trpcFactory'; const outputSchema = postsEncoder; -export const createPostInOrganization = router({ +export const createPostInOrganizationRouter = router({ createPost: loggedProcedure // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 3 })) .use(withAuthenticated) - .use(withDB) // Router // .meta(meta) .input( @@ -42,90 +38,15 @@ export const createPostInOrganization = router({ ) .output(outputSchema) .mutation(async ({ input, ctx }) => { - const { db } = ctx.database; - - const user = await getOrgAccessUser({ - organizationId: input.id, + const { result, allStorageObjects } = await createPostInOrganization({ + id: input.id, + content: input.content, + attachmentIds: input.attachmentIds, user: ctx.user, }); - if (!user) { - throw new UnauthorizedError(); - } - - try { - // Get all storage objects that were attached to the post - const allStorageObjects = - input.attachmentIds.length > 0 - ? await db.query.objectsInStorage.findMany({ - where: (table, { inArray }) => - inArray(table.id, input.attachmentIds), - }) - : []; - - const [post] = await db - .insert(posts) - .values({ - content: input.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[] = [ - db.insert(postsToOrganizations).values({ - organizationId: input.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: user.id, - fileName: - // @ts-expect-error - We check for this existence first. TODO: find the source of this TS error - storageObject?.name - ?.split('/') - .slice(-1)[0] - .split('_') - .slice(1) - .join('_') ?? '', - mimeType: (storageObject.metadata as { mimetype: string }).mimetype, - })); - - // @ts-ignore - queryPromises.push(db.insert(attachments).values(attachmentValues)); - } - - // Run attachments and join record in parallel - await Promise.all(queryPromises); - - // Track analytics (non-blocking) - waitUntil(trackUserPost(ctx.user.id, input.content, allStorageObjects)); - - const newPost = post; + waitUntil(trackUserPost(ctx.user.id, input.content, allStorageObjects)); - const output = outputSchema.parse({ - ...newPost, - reactionCounts: {}, - userReactions: [], - }); - return output; - } catch (error) { - console.log('ERROR', error); - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Something went wrong when adding post to organization', - }); - } + return outputSchema.parse(result); }), }); diff --git a/services/api/src/routers/organization/deletePost.ts b/services/api/src/routers/organization/deletePost.ts index f0443ffaf..97cf0f3ac 100644 --- a/services/api/src/routers/organization/deletePost.ts +++ b/services/api/src/routers/organization/deletePost.ts @@ -1,12 +1,9 @@ -import { UnauthorizedError, getOrgAccessUser } from '@op/common'; -import { posts, postsToOrganizations } from '@op/db/schema'; +import { UnauthorizedError, getOrgAccessUser, deletePostById } from '@op/common'; import { TRPCError } from '@trpc/server'; -import { and, eq } from 'drizzle-orm'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -26,7 +23,6 @@ export const deletePost = router({ deletePost: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 5 })) .use(withAuthenticated) - .use(withDB) .meta(meta) .input( z.object({ @@ -39,7 +35,6 @@ export const deletePost = router({ .output(z.object({ success: z.boolean() })) .mutation(async ({ input, ctx }) => { const { id, organizationId } = input; - const { db } = ctx.database; const user = await getOrgAccessUser({ organizationId, @@ -50,32 +45,16 @@ export const deletePost = router({ throw new UnauthorizedError(); } - // Verify the post exists and belongs to the organization - const postExists = await db - .select() - .from(posts) - .innerJoin( - postsToOrganizations, - eq(posts.id, postsToOrganizations.postId), - ) - .where( - and( - eq(posts.id, id), - eq(postsToOrganizations.organizationId, organizationId), - ), - ) - .limit(1); - - if (!postExists.length) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: - 'Post not found or does not belong to the specified organization', - }); + try { + return await deletePostById({ postId: id, organizationId }); + } catch (error) { + if (error instanceof Error && error.message.includes('Post not found')) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: error.message, + }); + } + throw error; } - - await db.delete(posts).where(eq(posts.id, id)); - - return { success: true }; }), }); diff --git a/services/api/src/routers/organization/getOrganization.ts b/services/api/src/routers/organization/getOrganization.ts index efd8d562d..3b907a2c3 100644 --- a/services/api/src/routers/organization/getOrganization.ts +++ b/services/api/src/routers/organization/getOrganization.ts @@ -13,7 +13,6 @@ import { organizationsWithProfileEncoder, } from '../../encoders/organizations'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -37,7 +36,6 @@ export const getOrganizationRouter = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router .meta(meta) .input(inputSchema) @@ -80,7 +78,6 @@ export const getOrganizationRouter = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router // .meta(meta) .input(z.object({ id: z.string() })) @@ -123,7 +120,6 @@ export const getOrganizationRouter = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router // .meta(meta) .input(z.object({ id: z.string(), termUri: z.string().optional() })) diff --git a/services/api/src/routers/organization/getOrganizationsByProfile.ts b/services/api/src/routers/organization/getOrganizationsByProfile.ts index 35e22457e..c3c174d7c 100644 --- a/services/api/src/routers/organization/getOrganizationsByProfile.ts +++ b/services/api/src/routers/organization/getOrganizationsByProfile.ts @@ -1,12 +1,10 @@ -import { sql } from '@op/db/client'; -import { locations } from '@op/db/schema'; +import { getOrganizationsByProfile } from '@op/common'; import { TRPCError } from '@trpc/server'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; import { organizationsWithProfileEncoder } from '../../encoders/organizations'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -25,82 +23,14 @@ export const getOrganizationsByProfileRouter = router({ getOrganizationsByProfile: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) .meta(meta) .input(z.object({ profileId: z.string().uuid() })) .output(z.array(organizationsWithProfileEncoder)) - .query(async ({ input, ctx }) => { - const { db } = ctx.database; + .query(async ({ input }) => { const { profileId } = input; try { - // 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`ST_X(${locations.location})`.as('x'), - y: sql`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); - } - } - } - - const organizations = Array.from(organizationMap.values()); + const organizations = await getOrganizationsByProfile(profileId); return organizations.map((org) => organizationsWithProfileEncoder.parse(org), diff --git a/services/api/src/routers/organization/index.ts b/services/api/src/routers/organization/index.ts index 64caaa098..27fefc015 100644 --- a/services/api/src/routers/organization/index.ts +++ b/services/api/src/routers/organization/index.ts @@ -2,7 +2,7 @@ import { mergeRouters } from '../../trpcFactory'; import { addRelationshipRouter } from './addRelationship'; import { approveRelationshipRouter } from './approveRelationship'; import { createOrganizationRouter } from './createOrganization'; -import { createPostInOrganization } from './createPostInOrganization'; +import { createPostInOrganizationRouter } from './createPostInOrganization'; import { declineRelationshipRouter } from './declineRelationship'; import { deletePost } from './deletePost'; import { getOrganizationRouter } from './getOrganization'; @@ -27,7 +27,7 @@ export const organizationRouter = mergeRouters( listOrganizationsRouter, listOrganizationPostsRouter, searchOrganizationsRouter, - createPostInOrganization, + createPostInOrganizationRouter, deletePost, createOrganizationRouter, updateOrganizationRouter, diff --git a/services/api/src/routers/organization/inviteUser.ts b/services/api/src/routers/organization/inviteUser.ts index 9db0b60d0..37927f251 100644 --- a/services/api/src/routers/organization/inviteUser.ts +++ b/services/api/src/routers/organization/inviteUser.ts @@ -1,12 +1,9 @@ -import { UnauthorizedError, sendInvitationEmail } from '@op/common'; -import { OPURLConfig } from '@op/core'; -import { allowList } from '@op/db/schema'; +import { UnauthorizedError, inviteUsersToOrganization } from '@op/common'; import { TRPCError } from '@trpc/server'; // import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -60,14 +57,12 @@ export const inviteUserRouter = router({ // Middlewares .use(withRateLimited({ windowSize: 60, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router // .meta(meta) .input(inputSchema) .output(outputSchema) .mutation(async ({ ctx, input }) => { try { - const { db } = ctx.database; const { id: authUserId } = ctx.user; // Handle both single email and multiple emails input @@ -77,131 +72,25 @@ export const inviteUserRouter = router({ const targetOrganizationId = input.organizationId; const personalMessage = input.personalMessage; - // 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, - }, + return await inviteUsersToOrganization({ + emails: emailsToProcess, + role, + organizationId: targetOrganizationId, + personalMessage, + authUserId, + authUserEmail: ctx.user.email, }); - - // 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 UnauthorizedError( - '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 emailsToProcess) { - 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 = targetOrganizationId - ? { - 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: targetOrganizationId ?? null, - metadata, - }); - } - - // Send invitation email - try { - await sendInvitationEmail({ - to: email, - inviterName: - authUser?.name || ctx.user.email || 'A team member', - organizationName: targetOrganizationId - ? (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 = emailsToProcess.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, - }, - }; } catch (error) { // Re-throw TRPCError as-is if (error instanceof TRPCError) { throw error; } + // Handle specific errors + if (error instanceof Error && error.message.includes('User must be associated')) { + throw new UnauthorizedError(error.message); + } + // Handle other errors const message = error instanceof Error ? error.message : 'Failed to send invitation'; diff --git a/services/api/src/routers/organization/listRelatedOrganizationPosts.ts b/services/api/src/routers/organization/listRelatedOrganizationPosts.ts index 0d8f4c045..e0448bf57 100644 --- a/services/api/src/routers/organization/listRelatedOrganizationPosts.ts +++ b/services/api/src/routers/organization/listRelatedOrganizationPosts.ts @@ -1,12 +1,4 @@ -import { - decodeCursor, - encodeCursor, - getCurrentProfileId, - getItemsWithReactionsAndComments, - getRelatedOrganizations, -} from '@op/common'; -import { and, eq, inArray, lt, or } from '@op/db/client'; -import { postsToOrganizations } from '@op/db/schema'; +import { listAllRelatedOrganizationPosts, listRelatedOrganizationPosts } from '@op/common'; // import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; @@ -19,7 +11,6 @@ import { postsToOrganizationsEncoder, } from '../../encoders/posts'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; import { dbFilter } from '../../utils'; @@ -54,7 +45,6 @@ export const listRelatedOrganizationPostsRouter = router({ listAllPosts: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // .meta(metaAllPosts) .input( dbFilter @@ -70,133 +60,37 @@ export const listRelatedOrganizationPostsRouter = router({ hasMore: z.boolean(), }), ) - .query(async ({ ctx, input }) => { - const { db } = ctx.database; + .query(async ({ input }) => { const { limit = 200, cursor } = input ?? {}; - // Parse cursor - const cursorData = cursor ? decodeCursor(cursor) : null; - - // Build cursor condition for pagination - const cursorCondition = cursorData - ? or( - lt(postsToOrganizations.createdAt, cursorData.createdAt), - and( - eq(postsToOrganizations.createdAt, cursorData.createdAt), - lt(postsToOrganizations.postId, cursorData.id), - ), - ) - : undefined; - - // Fetch posts for all organizations with pagination - const [result, profileId] = await Promise.all([ - db.query.postsToOrganizations.findMany({ - where: cursorCondition, - with: { - post: { - where: (table, { isNull }) => isNull(table.parentPostId), // Only show top-level posts - with: { - attachments: { - with: { - storageObject: true, - }, - }, - reactions: true, - }, - }, - organization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - }, - orderBy: (table, { desc }) => desc(table.createdAt), - limit: limit + 1, // Fetch one extra to check hasMore - }), - getCurrentProfileId(), - ]); - - // Filter out any items where post is null (due to parentPostId filtering) - const filteredResult = result.filter((item) => item.post !== null); - - const hasMore = filteredResult.length > limit; - const items = hasMore ? filteredResult.slice(0, limit) : filteredResult; - const lastItem = items[items.length - 1]; - const nextCursor = - hasMore && lastItem && lastItem.createdAt - ? encodeCursor(new Date(lastItem.createdAt), lastItem.postId) - : null; - - const itemsWithReactionsAndComments = - await getItemsWithReactionsAndComments({ items, profileId }); + const result = await listAllRelatedOrganizationPosts({ limit, cursor }); return { - items: itemsWithReactionsAndComments.map((postToOrg) => ({ + items: result.items.map((postToOrg) => ({ ...postToOrg, organization: organizationsEncoder.parse(postToOrg.organization), post: postsEncoder.parse(postToOrg.post), })), - next: nextCursor, - hasMore, + next: result.next, + hasMore: result.hasMore, }; }), listRelatedPosts: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // .meta(meta) .input(inputSchema) .output(z.array(postsToOrganizationsEncoder)) .query(async ({ ctx, input }) => { - const { db } = ctx.database; const { organizationId } = input; const { user } = ctx; - // Get related organizations - const { records: organizations } = await getRelatedOrganizations({ + const result = await listRelatedOrganizationPosts({ + organizationId, user, - orgId: organizationId, - pending: false, }); - const orgIds = organizations?.map((org: any) => org.id) ?? []; - orgIds.push(organizationId); // Add our own org so we see our own posts - - // Fetch posts for all related organizations - const result = await db.query.postsToOrganizations.findMany({ - where: () => inArray(postsToOrganizations.organizationId, orgIds), - with: { - post: { - where: (table, { isNull }) => isNull(table.parentPostId), // Only show top-level posts - with: { - attachments: { - with: { - storageObject: true, - }, - }, - }, - }, - organization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - }, - orderBy: (table, { desc }) => desc(table.createdAt), - }); - - // Filter out any items where post is null (due to parentPostId filtering) - const filteredResult = result.filter((item) => item.post !== null); - - return filteredResult.map((postToOrg) => ({ + return result.map((postToOrg) => ({ ...postToOrg, organization: organizationsWithProfileEncoder.parse( postToOrg.organization, diff --git a/services/api/src/routers/organization/reactions.ts b/services/api/src/routers/organization/reactions.ts index 04bc08414..1b95e0ecb 100644 --- a/services/api/src/routers/organization/reactions.ts +++ b/services/api/src/routers/organization/reactions.ts @@ -1,12 +1,9 @@ -import { CommonError, getCurrentProfileId } from '@op/common'; -import { postReactions } from '@op/db/schema'; +import { CommonError, getCurrentProfileId, addReaction, removeReaction, toggleReaction } from '@op/common'; import { VALID_REACTION_TYPES } from '@op/types'; import { TRPCError } from '@trpc/server'; -import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -16,37 +13,18 @@ export const reactionsRouter = router({ addReaction: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 20 })) .use(withAuthenticated) - .use(withDB) .input( z.object({ postId: z.string(), reactionType: reactionTypeEnum, }), ) - .mutation(async ({ input, ctx }) => { + .mutation(async ({ input }) => { const { postId, reactionType } = input; - const { database } = ctx; try { const profileId = await getCurrentProfileId(); - - // First, remove any existing reaction from this user on this post - await database.db - .delete(postReactions) - .where( - and( - eq(postReactions.postId, postId), - eq(postReactions.profileId, profileId), - ), - ); - - // Then add the new reaction - await database.db.insert(postReactions).values({ - postId, - profileId, - reactionType, - }); - + await addReaction({ postId, profileId, reactionType }); return { success: true }; } catch (error) { throw new TRPCError({ @@ -59,25 +37,16 @@ export const reactionsRouter = router({ removeReaction: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 20 })) .use(withAuthenticated) - .use(withDB) .input( z.object({ postId: z.string(), }), ) - .mutation(async ({ input, ctx }) => { + .mutation(async ({ input }) => { const { postId } = input; - const { database } = ctx; const profileId = await getCurrentProfileId(); - await database.db - .delete(postReactions) - .where( - and( - eq(postReactions.postId, postId), - eq(postReactions.profileId, profileId), - ), - ); + await removeReaction({ postId, profileId }); return { success: true }; }), @@ -85,74 +54,18 @@ export const reactionsRouter = router({ toggleReaction: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 20 })) .use(withAuthenticated) - .use(withDB) .input( z.object({ postId: z.string(), reactionType: reactionTypeEnum, }), ) - .mutation(async ({ input, ctx }) => { + .mutation(async ({ input }) => { const { postId, reactionType } = input; - const { database } = ctx; try { const profileId = await getCurrentProfileId(); - - // Check if user has any existing reaction on this post - const existingReaction = await database.db - .select() - .from(postReactions) - .where( - and( - eq(postReactions.postId, postId), - eq(postReactions.profileId, profileId), - ), - ) - .limit(1); - - if (existingReaction.length > 0) { - // If user has the same reaction type, remove it - if (existingReaction[0]?.reactionType === reactionType) { - await database.db - .delete(postReactions) - .where( - and( - eq(postReactions.postId, postId), - eq(postReactions.profileId, profileId), - ), - ); - - return { success: true, action: 'removed' }; - } else { - // If user has a different reaction type, replace it - await database.db - .delete(postReactions) - .where( - and( - eq(postReactions.postId, postId), - eq(postReactions.profileId, profileId), - ), - ); - - await database.db.insert(postReactions).values({ - postId, - profileId, - reactionType, - }); - - return { success: true, action: 'replaced' }; - } - } else { - // No existing reaction, add new one - await database.db.insert(postReactions).values({ - postId, - profileId, - reactionType, - }); - - return { success: true, action: 'added' }; - } + return await toggleReaction({ postId, profileId, reactionType }); } catch (e) { throw new CommonError('Failed to toggle reaction'); } diff --git a/services/api/src/routers/organization/updateOrganization.ts b/services/api/src/routers/organization/updateOrganization.ts index 51548db52..657b7571f 100644 --- a/services/api/src/routers/organization/updateOrganization.ts +++ b/services/api/src/routers/organization/updateOrganization.ts @@ -7,7 +7,6 @@ import type { OpenApiMeta } from 'trpc-to-openapi'; import { organizationsEncoder } from '../../encoders/organizations'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; import { updateOrganizationInputSchema } from './validators'; @@ -28,7 +27,6 @@ export const updateOrganizationRouter = router({ // Middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 20 })) .use(withAuthenticated) - .use(withDB) // Router .meta(meta) .input(updateOrganizationInputSchema) diff --git a/services/api/src/routers/organization/uploadAvatarImage.ts b/services/api/src/routers/organization/uploadAvatarImage.ts index 593b4bfdb..33b95802c 100644 --- a/services/api/src/routers/organization/uploadAvatarImage.ts +++ b/services/api/src/routers/organization/uploadAvatarImage.ts @@ -1,13 +1,12 @@ +import { trackImageUpload } from '@op/analytics'; import { createServerClient } from '@op/supabase/lib'; import { TRPCError } from '@trpc/server'; import { waitUntil } from '@vercel/functions'; import { Buffer } from 'buffer'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; -import { trackImageUpload } from '@op/analytics'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; import { MAX_FILE_SIZE, sanitizeS3Filename } from '../../utils'; @@ -37,7 +36,6 @@ export const uploadAvatarImage = router({ // middlewares .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) // Router .meta(meta) .input( @@ -149,11 +147,11 @@ export const uploadAvatarImage = router({ logger.info( 'RETURNING UPLOAD URL' + signedUrlData.signedUrl + ' - ' + filePath, ); - + // Track analytics - for organization uploads, we'll track as new uploads since they're temporary (non-blocking) const imageType = filePath.includes('banner') ? 'banner' : 'profile'; waitUntil(trackImageUpload(ctx.user.id, imageType, false)); - + return { url: signedUrlData.signedUrl, path: filePath, diff --git a/services/api/src/routers/taxonomy/taxonomyTerms.ts b/services/api/src/routers/taxonomy/taxonomyTerms.ts index 79469c77c..f6c5b52b6 100644 --- a/services/api/src/routers/taxonomy/taxonomyTerms.ts +++ b/services/api/src/routers/taxonomy/taxonomyTerms.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import { taxonomyTermsWithChildrenEncoder } from '../../encoders/taxonomyTerms'; import withAuthenticated from '../../middlewares/withAuthenticated'; -import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; @@ -11,7 +10,6 @@ export const termsRouter = router({ getTerms: loggedProcedure .use(withRateLimited({ windowSize: 10, maxRequests: 10 })) .use(withAuthenticated) - .use(withDB) .input(z.object({ name: z.string().min(3), q: z.string().optional() })) .output(z.array(taxonomyTermsWithChildrenEncoder)) .query(async ({ input }) => {