From bb0915308021540368bce2989f772eb957ad8971 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Mon, 19 Jan 2026 17:47:07 +0100 Subject: [PATCH 1/3] join profile relational queries v2 --- .../assertTargetProfileAdminAccess.ts | 11 ++--- .../requests/deleteProfileJoinRequest.ts | 7 +-- .../requests/listProfileJoinRequests.ts | 33 +++++++------- .../src/services/profile/requests/types.ts | 15 ++++--- .../requests/updateProfileJoinRequest.ts | 28 ++++++------ .../validateJoinProfileRequestContext.ts | 15 +++---- services/db/relations.ts | 45 +++++++++++++++++++ 7 files changed, 98 insertions(+), 56 deletions(-) create mode 100644 services/db/relations.ts diff --git a/packages/common/src/services/profile/requests/assertTargetProfileAdminAccess.ts b/packages/common/src/services/profile/requests/assertTargetProfileAdminAccess.ts index c32e8c99c..0d309f1ae 100644 --- a/packages/common/src/services/profile/requests/assertTargetProfileAdminAccess.ts +++ b/packages/common/src/services/profile/requests/assertTargetProfileAdminAccess.ts @@ -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'; @@ -29,8 +28,10 @@ export const assertTargetProfileAdminAccess = async ({ }): Promise => { const [targetProfile, organization] = await Promise.all([ assertProfile(targetProfileId), - db._query.organizations.findFirst({ - where: eq(organizations.profileId, targetProfileId), + db.query.organizations.findFirst({ + where: { + profileId: targetProfileId, + }, }), ]); diff --git a/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts b/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts index 135816dc6..8acedff77 100644 --- a/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts +++ b/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts @@ -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. @@ -18,7 +17,7 @@ export const deleteProfileJoinRequest = async ({ user: User; /** The ID of the join profile request to delete */ requestId: string; -}): Promise => { +}) => { // Find the existing request by ID with profiles const existingRequest = await db._query.joinProfileRequests.findFirst({ where: (table, { eq }) => eq(table.id, requestId), @@ -56,7 +55,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; }; diff --git a/packages/common/src/services/profile/requests/listProfileJoinRequests.ts b/packages/common/src/services/profile/requests/listProfileJoinRequests.ts index a372093e9..4c8de754a 100644 --- a/packages/common/src/services/profile/requests/listProfileJoinRequests.ts +++ b/packages/common/src/services/profile/requests/listProfileJoinRequests.ts @@ -1,11 +1,13 @@ import { db } from '@op/db/client'; -import { JoinProfileRequestStatus, joinProfileRequests } from '@op/db/schema'; -import { User } from '@op/supabase/lib'; +import { + type JoinProfileRequestStatus, + joinProfileRequests, +} from '@op/db/schema'; +import type { User } from '@op/supabase/lib'; import { and, eq } from 'drizzle-orm'; import { decodeCursor, encodeCursor, getCursorCondition } from '../../../utils'; import { assertTargetProfileAdminAccess } from './assertTargetProfileAdminAccess'; -import { JoinProfileRequestWithProfiles } from './types'; type ListJoinProfileRequestsCursor = { value: string; @@ -30,11 +32,7 @@ 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({ @@ -45,7 +43,7 @@ export const listProfileJoinRequests = async ({ }) : undefined; - // Build where clause + // Build where clause using SQL expressions (v2 object-style where doesn't support complex AND/OR with SQL) const whereClause = and( eq(joinProfileRequests.targetProfileId, targetProfileId), status ? eq(joinProfileRequests.status, status) : undefined, @@ -54,14 +52,17 @@ export const listProfileJoinRequests = async ({ const [, results] = await Promise.all([ assertTargetProfileAdminAccess({ user, targetProfileId }), - db._query.joinProfileRequests.findMany({ - where: whereClause, + db.query.joinProfileRequests.findMany({ + where: { + RAW: whereClause, + }, with: { requestProfile: true, targetProfile: true, }, - orderBy: (table, { asc, desc }) => - dir === 'asc' ? asc(table.createdAt) : desc(table.createdAt), + orderBy: { + createdAt: dir, + }, limit: limit + 1, }), ]); @@ -79,11 +80,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, }; diff --git a/packages/common/src/services/profile/requests/types.ts b/packages/common/src/services/profile/requests/types.ts index c373a28ea..017626700 100644 --- a/packages/common/src/services/profile/requests/types.ts +++ b/packages/common/src/services/profile/requests/types.ts @@ -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> +>; export type JoinProfileRequestContext = { requestProfile: Profile; diff --git a/packages/common/src/services/profile/requests/updateProfileJoinRequest.ts b/packages/common/src/services/profile/requests/updateProfileJoinRequest.ts index 087669bbf..f4a755d5a 100644 --- a/packages/common/src/services/profile/requests/updateProfileJoinRequest.ts +++ b/packages/common/src/services/profile/requests/updateProfileJoinRequest.ts @@ -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. @@ -29,8 +29,8 @@ export const updateProfileJoinRequest = async ({ status: JoinProfileRequestStatus.APPROVED | JoinProfileRequestStatus.REJECTED; }): Promise => { // 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) { @@ -85,9 +85,8 @@ 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) { @@ -95,12 +94,11 @@ export const updateProfileJoinRequest = async ({ // 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 @@ -108,8 +106,8 @@ export const updateProfileJoinRequest = async ({ // 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) { diff --git a/packages/common/src/services/profile/requests/validateJoinProfileRequestContext.ts b/packages/common/src/services/profile/requests/validateJoinProfileRequestContext.ts index 5d997e77d..fd060ca25 100644 --- a/packages/common/src/services/profile/requests/validateJoinProfileRequestContext.ts +++ b/packages/common/src/services/profile/requests/validateJoinProfileRequestContext.ts @@ -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. @@ -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, + }, }), ]); diff --git a/services/db/relations.ts b/services/db/relations.ts new file mode 100644 index 000000000..e28a14255 --- /dev/null +++ b/services/db/relations.ts @@ -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: {}, +})); From 0a39f95f08c07e15613d292e5e2dd3dceb650500 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Mon, 19 Jan 2026 18:45:28 +0100 Subject: [PATCH 2/3] old query --- .../requests/deleteProfileJoinRequest.ts | 4 +-- .../requests/listProfileJoinRequests.ts | 26 ++++++++----------- services/db/index.ts | 2 ++ 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts b/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts index 8acedff77..7713cae8e 100644 --- a/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts +++ b/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts @@ -19,8 +19,8 @@ export const deleteProfileJoinRequest = async ({ requestId: string; }) => { // 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, diff --git a/packages/common/src/services/profile/requests/listProfileJoinRequests.ts b/packages/common/src/services/profile/requests/listProfileJoinRequests.ts index 4c8de754a..9fa09d2e2 100644 --- a/packages/common/src/services/profile/requests/listProfileJoinRequests.ts +++ b/packages/common/src/services/profile/requests/listProfileJoinRequests.ts @@ -4,7 +4,6 @@ import { joinProfileRequests, } from '@op/db/schema'; import type { User } from '@op/supabase/lib'; -import { and, eq } from 'drizzle-orm'; import { decodeCursor, encodeCursor, getCursorCondition } from '../../../utils'; import { assertTargetProfileAdminAccess } from './assertTargetProfileAdminAccess'; @@ -43,26 +42,23 @@ export const listProfileJoinRequests = async ({ }) : undefined; - // Build where clause using SQL expressions (v2 object-style where doesn't support complex AND/OR with SQL) - 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: { - RAW: whereClause, - }, + db._query.joinProfileRequests.findMany({ + where: (table, { and, eq }) => + and( + eq(table.targetProfileId, targetProfileId), + status ? eq(table.status, status) : undefined, + cursorCondition, + ), with: { requestProfile: true, targetProfile: true, }, - orderBy: { - createdAt: dir, - }, + orderBy: (_, { asc, desc }) => + dir === 'asc' + ? asc(joinProfileRequests.createdAt) + : desc(joinProfileRequests.createdAt), limit: limit + 1, }), ]); diff --git a/services/db/index.ts b/services/db/index.ts index 44bd98ca2..0f0906401 100644 --- a/services/db/index.ts +++ b/services/db/index.ts @@ -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 @@ -30,5 +31,6 @@ export const db = drizzle({ }, casing: config.casing, schema, + relations, logger: process.env.NODE_ENV !== 'test', }); From 2ccddad4e68146009841cffcb0a0f3a16b1d2e5c Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Mon, 19 Jan 2026 18:55:38 +0100 Subject: [PATCH 3/3] cursor inline --- .../requests/deleteProfileJoinRequest.ts | 11 ++--- .../requests/listProfileJoinRequests.ts | 49 ++++++++++--------- packages/common/src/utils/db.ts | 4 +- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts b/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts index 7713cae8e..6b1a125cc 100644 --- a/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts +++ b/packages/common/src/services/profile/requests/deleteProfileJoinRequest.ts @@ -37,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) { diff --git a/packages/common/src/services/profile/requests/listProfileJoinRequests.ts b/packages/common/src/services/profile/requests/listProfileJoinRequests.ts index 9fa09d2e2..de0ea2386 100644 --- a/packages/common/src/services/profile/requests/listProfileJoinRequests.ts +++ b/packages/common/src/services/profile/requests/listProfileJoinRequests.ts @@ -1,11 +1,9 @@ import { db } from '@op/db/client'; -import { - type JoinProfileRequestStatus, - joinProfileRequests, -} from '@op/db/schema'; +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'; type ListJoinProfileRequestsCursor = { @@ -32,33 +30,36 @@ export const listProfileJoinRequests = async ({ limit?: number; dir?: 'asc' | 'desc'; }) => { - // Build cursor condition for pagination - const cursorCondition = cursor - ? getCursorCondition({ - column: joinProfileRequests.createdAt, - tieBreakerColumn: joinProfileRequests.id, - cursor: decodeCursor(cursor), - direction: dir, - }) + const decodedCursor = cursor + ? decodeCursor(cursor) : undefined; const [, results] = await Promise.all([ assertTargetProfileAdminAccess({ user, targetProfileId }), - db._query.joinProfileRequests.findMany({ - where: (table, { and, eq }) => - and( - eq(table.targetProfileId, targetProfileId), - status ? eq(table.status, status) : undefined, - cursorCondition, - ), + 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 & {}; + }, + }), + }, with: { requestProfile: true, targetProfile: true, }, - orderBy: (_, { asc, desc }) => - dir === 'asc' - ? asc(joinProfileRequests.createdAt) - : desc(joinProfileRequests.createdAt), + orderBy: { + createdAt: dir, + }, limit: limit + 1, }), ]); diff --git a/packages/common/src/utils/db.ts b/packages/common/src/utils/db.ts index dc08c60e2..cb451b173 100644 --- a/packages/common/src/utils/db.ts +++ b/packages/common/src/utils/db.ts @@ -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'; @@ -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(' ', '\\ ')}:*`})`;