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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ The migration generator compares entities against the local database schema. Ens
- **Docs**: See `src/graphorm/AGENTS.md` for comprehensive guide on using GraphORM to solve N+1 queries. GraphORM is the default and preferred method for all GraphQL query responses. Use GraphORM instead of TypeORM repositories for GraphQL resolvers to prevent N+1 queries and enforce best practices.
- **GraphORM mappings**: Only add entries in `src/graphorm/index.ts` when you need custom mapping/fields/transforms or GraphQL type names differ from TypeORM entity names. For straightforward reads, keep GraphQL type names aligned with entities and use GraphORM without extra config.
- For GraphQL query resolvers, prefer `graphorm.query`, `graphorm.queryOne`, or `graphorm.queryPaginated` over custom TypeORM fetch/pagination code whenever GraphORM can express the query. Reach for manual TypeORM reads only when GraphORM genuinely cannot support the access pattern.
- When a GraphQL field must be nulled based on viewer permissions, define the rule in `src/graphorm/index.ts` as a shared transform/helper (for example `nullIfNotLoggedIn`). If a resolver cannot use GraphORM for the full query, reuse that GraphORM field mapping from the manual path instead of re-implementing the permission rule in a schema field resolver.
- For offset-paginated GraphQL reads that only need `pageInfo.hasNextPage`, prefer overfetching one extra row and slicing it in the page generator. Avoid separate `COUNT(*)`/`COUNT(DISTINCT ...)` queries unless the client explicitly needs a total.

**Data Layer:**
Expand Down
15 changes: 15 additions & 0 deletions __tests__/schema/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const USER_FEEDBACK_QUERY = `
id
category
description
linearIssueUrl
status
createdAt
replies {
Expand Down Expand Up @@ -90,6 +91,7 @@ const FEEDBACK_LIST_QUERY = `
edges {
node {
id
linearIssueUrl
status
category
user {
Expand Down Expand Up @@ -118,6 +120,7 @@ const USER_FEEDBACK_BY_USER_ID_QUERY = `
id
category
description
linearIssueUrl
status
user {
id
Expand Down Expand Up @@ -148,6 +151,7 @@ describe('feedback schema', () => {
userId: '1',
category: UserFeedbackCategory.BUG,
description: 'Own feedback',
linearIssueUrl: 'https://linear.app/dailydev/issue/ENG-1159',
status: FeedbackStatus.Pending,
flags: {},
});
Expand Down Expand Up @@ -178,6 +182,7 @@ describe('feedback schema', () => {
expect(res.data.userFeedback.edges[0].node).toMatchObject({
id: ownFeedback.id,
description: 'Own feedback',
linearIssueUrl: null,
replies: [{ body: 'Thanks for sharing', authorName: 'Chris' }],
});
});
Expand All @@ -197,6 +202,7 @@ describe('feedback schema', () => {
userId: '1',
category: UserFeedbackCategory.BUG,
description: 'Pending feedback',
linearIssueUrl: 'https://linear.app/dailydev/issue/ENG-1159',
status: FeedbackStatus.Pending,
flags: {},
});
Expand Down Expand Up @@ -227,6 +233,7 @@ describe('feedback schema', () => {
expect(res.data.feedbackList.edges).toHaveLength(1);
expect(res.data.feedbackList.edges[0].node).toMatchObject({
id: pendingFeedback.id,
linearIssueUrl: 'https://linear.app/dailydev/issue/ENG-1159',
user: {
id: '1',
},
Expand All @@ -238,6 +245,7 @@ describe('feedback schema', () => {
userId: '1',
category: UserFeedbackCategory.CONTENT_QUALITY,
description: 'Matched feedback',
linearIssueUrl: 'https://linear.app/dailydev/issue/ENG-1159',
status: FeedbackStatus.Completed,
flags: {},
});
Expand Down Expand Up @@ -271,6 +279,7 @@ describe('feedback schema', () => {
expect(res.data.feedbackList.edges).toHaveLength(1);
expect(res.data.feedbackList.edges[0].node).toMatchObject({
id: matched.id,
linearIssueUrl: 'https://linear.app/dailydev/issue/ENG-1159',
status: FeedbackStatus.Completed,
category: UserFeedbackCategory.CONTENT_QUALITY,
user: {
Expand Down Expand Up @@ -309,6 +318,7 @@ describe('feedback schema', () => {
userId: '2',
category: UserFeedbackCategory.BUG,
description: 'User 2 feedback 1',
linearIssueUrl: 'https://linear.app/dailydev/issue/ENG-1158',
status: FeedbackStatus.Pending,
flags: {},
});
Expand All @@ -317,6 +327,7 @@ describe('feedback schema', () => {
userId: '2',
category: UserFeedbackCategory.FEATURE_REQUEST,
description: 'User 2 feedback 2',
linearIssueUrl: 'https://linear.app/dailydev/issue/ENG-1159',
status: FeedbackStatus.Processing,
flags: {},
});
Expand Down Expand Up @@ -348,13 +359,17 @@ describe('feedback schema', () => {
res.data.userFeedbackByUserId.edges.map((edge) => edge.node.id),
).toEqual([secondFeedback.id, firstFeedback.id]);
expect(res.data.userFeedbackByUserId.edges[0].node).toMatchObject({
linearIssueUrl: 'https://linear.app/dailydev/issue/ENG-1159',
user: {
id: '2',
},
replies: [
{ body: 'Thanks, we are investigating', authorName: 'Support' },
],
});
expect(res.data.userFeedbackByUserId.edges[1].node).toMatchObject({
linearIssueUrl: 'https://linear.app/dailydev/issue/ENG-1158',
});
});

it('should paginate userFeedbackByUserId results for team members', async () => {
Expand Down
17 changes: 17 additions & 0 deletions src/graphorm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { type Organization } from '../entity/Organization';
import {
OrganizationMemberRole,
Roles,
SourceMemberRoles,
rankToSourceRole,
sourceRoleRank,
Expand Down Expand Up @@ -136,6 +137,15 @@ const existsByUserAndHotTake =
const nullIfNotLoggedIn = <T>(value: T, ctx: Context): T | null =>
ctx.userId ? value : null;

export const nullIfNotTeamMember = <
T,
Ctx extends Pick<Context, 'isTeamMember' | 'roles'>,
>(
value: T,
ctx: Ctx,
): T | null =>
ctx.isTeamMember || ctx.roles.includes(Roles.Moderator) ? value : null;

const nullIfNotSameUser = <T>(
value: T,
ctx: Context,
Expand Down Expand Up @@ -2454,6 +2464,13 @@ const obj = new GraphORM({
},
},
},
FeedbackItem: {
fields: {
linearIssueUrl: {
transform: nullIfNotTeamMember,
},
},
},
Achievement: {
fields: {
criteria: { jsonType: true },
Expand Down
19 changes: 19 additions & 0 deletions src/schema/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
import { Roles } from '../roles';
import { queryReadReplica } from '../common/queryReadReplica';
import { QuestEventType } from '../entity/Quest';
import graphorm from '../graphorm';
import { Context } from '../Context';

type GQLFeedbackInput = {
category: number;
Expand All @@ -45,6 +47,7 @@ type GQLFeedbackItem = Pick<
| 'id'
| 'category'
| 'description'
| 'linearIssueUrl'
| 'status'
| 'screenshotUrl'
| 'createdAt'
Expand Down Expand Up @@ -137,6 +140,7 @@ export const typeDefs = /* GraphQL */ `
id: ID!
category: ProtoEnumValue!
description: String!
linearIssueUrl: String
status: Int!
screenshotUrl: String
createdAt: DateTime!
Expand Down Expand Up @@ -214,11 +218,13 @@ const mapRepliesByFeedbackId = ({
};

const fetchFeedbackConnectionNodes = async ({
ctx,
manager,
where,
page,
includeUsers,
}: {
ctx: BaseContext;
manager: EntityManager;
where: FindOptionsWhere<Feedback>;
page: ReturnType<typeof feedbackPageGenerator.connArgsToPage>;
Expand Down Expand Up @@ -255,11 +261,21 @@ const fetchFeedbackConnectionNodes = async ({
usersById = new Map(users.map((user) => [user.id, user]));
}

const linearIssueUrlTransform =
graphorm.mappings?.FeedbackItem?.fields?.linearIssueUrl?.transform;

return {
nodes: feedbackItems.map((feedback) => ({
id: feedback.id,
category: feedback.category,
description: feedback.description,
linearIssueUrl: linearIssueUrlTransform
? linearIssueUrlTransform(
feedback.linearIssueUrl,
ctx as Context,
feedback,
)
: feedback.linearIssueUrl,
status: feedback.status,
screenshotUrl: feedback.screenshotUrl,
createdAt: feedback.createdAt,
Expand All @@ -284,6 +300,7 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
ctx.con,
({ queryRunner }) =>
fetchFeedbackConnectionNodes({
ctx,
manager: queryRunner.manager,
where: { userId: ctx.userId },
page,
Expand Down Expand Up @@ -314,6 +331,7 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
ctx.con,
({ queryRunner }) =>
fetchFeedbackConnectionNodes({
ctx,
manager: queryRunner.manager,
where: { userId: args.userId },
page,
Expand Down Expand Up @@ -361,6 +379,7 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
ctx.con,
({ queryRunner }) =>
fetchFeedbackConnectionNodes({
ctx,
manager: queryRunner.manager,
where,
page,
Expand Down
Loading