Skip to content
Draft
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
@@ -1,8 +1,7 @@
import { db } from '@op/db/client';
import { type Organization, type Profile, organizations } from '@op/db/schema';
import { User } from '@op/supabase/lib';
import type { Organization, Profile } from '@op/db/schema';
import type { User } from '@op/supabase/lib';
import { assertAccess, permission } from 'access-zones';
import { eq } from 'drizzle-orm';

import { UnauthorizedError } from '../../../utils';
import { getOrgAccessUser } from '../../access';
Expand All @@ -29,8 +28,10 @@ export const assertTargetProfileAdminAccess = async ({
}): Promise<TargetProfileAdminContext> => {
const [targetProfile, organization] = await Promise.all([
assertProfile(targetProfileId),
db._query.organizations.findFirst({
where: eq(organizations.profileId, targetProfileId),
db.query.organizations.findFirst({
where: {
profileId: targetProfileId,
},
}),
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { User } from '@op/supabase/lib';
import { eq } from 'drizzle-orm';

import { UnauthorizedError, ValidationError } from '../../../utils';
import { JoinProfileRequestWithProfiles } from './types';

/**
* Deletes (cancels) a pending join profile request.
Expand All @@ -18,10 +17,10 @@ export const deleteProfileJoinRequest = async ({
user: User;
/** The ID of the join profile request to delete */
requestId: string;
}): Promise<JoinProfileRequestWithProfiles> => {
}) => {
// Find the existing request by ID with profiles
const existingRequest = await db._query.joinProfileRequests.findFirst({
where: (table, { eq }) => eq(table.id, requestId),
const existingRequest = await db.query.joinProfileRequests.findFirst({
where: { id: requestId },
with: {
requestProfile: true,
targetProfile: true,
Expand All @@ -38,12 +37,11 @@ export const deleteProfileJoinRequest = async ({
}

// Check authorization - user must own the requesting profile
const requestingUser = await db._query.users.findFirst({
where: (table, { and, eq }) =>
and(
eq(table.authUserId, user.id),
eq(table.profileId, existingRequest.requestProfileId),
),
const requestingUser = await db.query.users.findFirst({
where: {
authUserId: user.id,
profileId: existingRequest.requestProfileId,
},
});

if (!requestingUser) {
Expand All @@ -56,7 +54,5 @@ export const deleteProfileJoinRequest = async ({
.delete(joinProfileRequests)
.where(eq(joinProfileRequests.id, requestId));

// Type assertion needed because Drizzle's relational query types don't properly
// infer the `with` clause relations as non-array single objects
return existingRequest as JoinProfileRequestWithProfiles;
return existingRequest;
};
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { db } from '@op/db/client';
import { JoinProfileRequestStatus, joinProfileRequests } from '@op/db/schema';
import { User } from '@op/supabase/lib';
import { and, eq } from 'drizzle-orm';
import type { JoinProfileRequestStatus } from '@op/db/schema';
import type { User } from '@op/supabase/lib';
import { and, eq, gt, lt, or } from 'drizzle-orm';

import { decodeCursor, encodeCursor, getCursorCondition } from '../../../utils';
import { decodeCursor, encodeCursor } from '../../../utils';
import { assertTargetProfileAdminAccess } from './assertTargetProfileAdminAccess';
import { JoinProfileRequestWithProfiles } from './types';

type ListJoinProfileRequestsCursor = {
value: string;
Expand All @@ -30,38 +29,37 @@ export const listProfileJoinRequests = async ({
cursor?: string | null;
limit?: number;
dir?: 'asc' | 'desc';
}): Promise<{
items: JoinProfileRequestWithProfiles[];
next: string | null;
hasMore: boolean;
}> => {
// Build cursor condition for pagination
const cursorCondition = cursor
? getCursorCondition({
column: joinProfileRequests.createdAt,
tieBreakerColumn: joinProfileRequests.id,
cursor: decodeCursor<ListJoinProfileRequestsCursor>(cursor),
direction: dir,
})
}) => {
const decodedCursor = cursor
? decodeCursor<ListJoinProfileRequestsCursor>(cursor)
: undefined;

// Build where clause
const whereClause = and(
eq(joinProfileRequests.targetProfileId, targetProfileId),
status ? eq(joinProfileRequests.status, status) : undefined,
cursorCondition,
);

const [, results] = await Promise.all([
assertTargetProfileAdminAccess({ user, targetProfileId }),
db._query.joinProfileRequests.findMany({
where: whereClause,
db.query.joinProfileRequests.findMany({
where: {
targetProfileId,
...(status && { status }),
...(decodedCursor && {
RAW: (table) => {
const compareFn = dir === 'asc' ? gt : lt;
return or(
compareFn(table.createdAt, decodedCursor.value),
and(
eq(table.createdAt, decodedCursor.value),
compareFn(table.id, decodedCursor.id),
),
) as ReturnType<typeof or> & {};
},
}),
},
with: {
requestProfile: true,
targetProfile: true,
},
orderBy: (table, { asc, desc }) =>
dir === 'asc' ? asc(table.createdAt) : desc(table.createdAt),
orderBy: {
createdAt: dir,
},
limit: limit + 1,
}),
]);
Expand All @@ -79,11 +77,7 @@ export const listProfileJoinRequests = async ({
: null;

return {
// Type assertion needed because Drizzle's relational queries infer relations as
// { [x: string]: any } | { [x: string]: any }[] instead of the actual Profile type.
// This is a known Drizzle ORM limitation (see github.com/drizzle-team/drizzle-orm/issues/695)
// TODO: Re-check if this is still needed after upgrading to Drizzle v1
items: items as JoinProfileRequestWithProfiles[],
items,
next: nextCursor,
hasMore,
};
Expand Down
15 changes: 10 additions & 5 deletions packages/common/src/services/profile/requests/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { JoinProfileRequest, Profile } from '@op/db/schema';
import type { JoinProfileRequest, Profile } from '@op/db/schema';

export type JoinProfileRequestWithProfiles = JoinProfileRequest & {
requestProfile: Profile;
targetProfile: Profile;
};
import type { deleteProfileJoinRequest } from './deleteProfileJoinRequest';

/**
* A join profile request with its associated request and target profiles.
* This type is inferred from Drizzle v2 relational queries with the `with` clause.
*/
export type JoinProfileRequestWithProfiles = NonNullable<
Awaited<ReturnType<typeof deleteProfileJoinRequest>>
>;

export type JoinProfileRequestContext = {
requestProfile: Profile;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import {
organizationUserToAccessRoles,
organizationUsers,
} from '@op/db/schema';
import { User } from '@op/supabase/lib';
import type { User } from '@op/supabase/lib';
import { eq } from 'drizzle-orm';

import { CommonError, ValidationError } from '../../../utils';
import { assertProfile } from '../../assert';
import { assertTargetProfileAdminAccess } from './assertTargetProfileAdminAccess';
import { JoinProfileRequestWithProfiles } from './types';
import type { JoinProfileRequestWithProfiles } from './types';

/**
* Updates the status of an existing join profile request to approved or rejected.
Expand All @@ -29,8 +29,8 @@ export const updateProfileJoinRequest = async ({
status: JoinProfileRequestStatus.APPROVED | JoinProfileRequestStatus.REJECTED;
}): Promise<JoinProfileRequestWithProfiles> => {
// Find the existing request by ID
const existingRequest = await db._query.joinProfileRequests.findFirst({
where: (table, { eq }) => eq(table.id, requestId),
const existingRequest = await db.query.joinProfileRequests.findFirst({
where: { id: requestId },
});

if (!existingRequest) {
Expand Down Expand Up @@ -85,31 +85,29 @@ export const updateProfileJoinRequest = async ({
}

// Get the owner of the requesting profile (their authUserId)
const requestingUser = await db._query.users.findFirst({
where: (table, { eq }) =>
eq(table.profileId, existingRequest.requestProfileId),
const requestingUser = await db.query.users.findFirst({
where: { profileId: existingRequest.requestProfileId },
});

if (requestingUser) {
// Check if user is already a member of the target organization.
// NOTE: We're using organizationUsers instead of profileUsers because we're in between
// memberships - the profile user membership (new) and the organization user membership (old).
// After we migrate to profile users, this code should be changed to use profileUsers.
const existingMembership = await db._query.organizationUsers.findFirst({
where: (table, { and, eq }) =>
and(
eq(table.authUserId, requestingUser.authUserId),
eq(table.organizationId, organization.id),
),
const existingMembership = await db.query.organizationUsers.findFirst({
where: {
authUserId: requestingUser.authUserId,
organizationId: organization.id,
},
});

// Only create membership if it doesn't already exist
if (!existingMembership) {
// TODO: We should find a better way to reference the Member role
// rather than querying by name. Consider using a constant ID or
// a more robust role resolution mechanism.
const memberRole = await db._query.accessRoles.findFirst({
where: (table, { eq }) => eq(table.name, 'Member'),
const memberRole = await db.query.accessRoles.findFirst({
where: { name: 'Member' },
});

if (!memberRole) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import {
profiles,
users,
} from '@op/db/schema';
import { User } from '@op/supabase/lib';
import type { User } from '@op/supabase/lib';
import { and, eq, inArray } from 'drizzle-orm';

import { UnauthorizedError, ValidationError } from '../../../utils';
import { JoinProfileRequestContext } from './types';
import type { JoinProfileRequestContext } from './types';

/**
* Fetches and validates the context needed for join profile request operations.
Expand Down Expand Up @@ -57,12 +57,11 @@ export const validateJoinProfileRequestContext = async ({
)
.where(inArray(profiles.id, [requestProfileId, targetProfileId])),

db._query.joinProfileRequests.findFirst({
where: (table, { and, eq }) =>
and(
eq(table.requestProfileId, requestProfileId),
eq(table.targetProfileId, targetProfileId),
),
db.query.joinProfileRequests.findFirst({
where: {
requestProfileId,
targetProfileId,
},
}),
]);

Expand Down
4 changes: 2 additions & 2 deletions packages/common/src/utils/db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { and, eq, gt, lt, or, sql } from 'drizzle-orm';
import { PgColumn } from 'drizzle-orm/pg-core';
import type { PgColumn } from 'drizzle-orm/pg-core';

import { CommonError } from './error';

Expand Down Expand Up @@ -84,4 +84,4 @@ export const constructTextSearch = ({
column: PgColumn;
query: string;
}) =>
sql`to_tsvector('english', ${column}) @@to_tsquery('english', ${query.trim().replaceAll(' ', '\\ ') + ':*'})`;
sql`to_tsvector('english', ${column}) @@to_tsquery('english', ${`${query.trim().replaceAll(' ', '\\ ')}:*`})`;
2 changes: 2 additions & 0 deletions services/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import dotenv from 'dotenv';
import { drizzle } from 'drizzle-orm/postgres-js';

import config from './drizzle.config';
import { relations } from './relations';
import * as schema from './schema';

// For local development, we need to load the .env.local file from the root of the monorepo
Expand Down Expand Up @@ -30,5 +31,6 @@ export const db = drizzle({
},
casing: config.casing,
schema,
relations,
logger: process.env.NODE_ENV !== 'test',
});
45 changes: 45 additions & 0 deletions services/db/relations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { defineRelations } from 'drizzle-orm';

import * as schema from './schema';

/**
* Drizzle Relations v2 definitions
*
* This file defines relations using the new v2 syntax which enables:
* - Object-based `where` clauses in queries
* - Many-to-many relations with `through`
* - Predefined filters on relations
*
* Relations are incrementally migrated from v1 (in individual table files)
* to v2 (here) as queries are updated.
*/
export const relations = defineRelations(schema, (r) => ({
joinProfileRequests: {
requestProfile: r.one.profiles({
from: r.joinProfileRequests.requestProfileId,
to: r.profiles.id,
optional: false,
}),
targetProfile: r.one.profiles({
from: r.joinProfileRequests.targetProfileId,
to: r.profiles.id,
optional: false,
}),
},
users: {
profile: r.one.profiles({
from: r.users.profileId,
to: r.profiles.id,
}),
},
organizations: {
profile: r.one.profiles({
from: r.organizations.profileId,
to: r.profiles.id,
optional: false,
}),
},
// Empty relations needed for v2 query API on tables without relations
organizationUsers: {},
accessRoles: {},
}));