Skip to content

Commit e16d8e9

Browse files
authored
feat(sentiment): move sentiment brands from apps to daily-api (#3638)
1 parent 64dcb26 commit e16d8e9

7 files changed

Lines changed: 423 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ The migration generator compares entities against the local database schema. Ens
8484
- `src/schema/` - GraphQL resolvers organized by domain (posts, users, feeds, etc.)
8585
- `src/directive/` - Custom GraphQL directives for auth, rate limiting, URL processing
8686
- **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.
87+
- **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.
8788

8889
**Data Layer:**
8990

__tests__/schema/sentiment.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { deleteKeysByPattern } from '../../src/redis';
1212
import { rateLimiterName } from '../../src/directive/rateLimit';
1313
import { yggdrasilSentimentClient } from '../../src/integrations/yggdrasil/clients';
1414
import { HttpError } from '../../src/integrations/retry';
15+
import { SentimentEntity } from '../../src/entity/SentimentEntity';
16+
import { SentimentGroup } from '../../src/entity/SentimentGroup';
1517

1618
jest.mock('../../src/integrations/yggdrasil/clients', () => ({
1719
yggdrasilSentimentClient: {
@@ -467,3 +469,138 @@ describe('query sentimentHighlights', () => {
467469
);
468470
});
469471
});
472+
473+
describe('query sentimentGroup', () => {
474+
const normalizeGroup = (group: {
475+
id: string;
476+
name: string;
477+
entities: { entity: string; name: string; logo: string }[];
478+
}) => ({
479+
...group,
480+
entities: [...group.entities].sort((a, b) =>
481+
a.entity.localeCompare(b.entity),
482+
),
483+
});
484+
485+
const QUERY = /* GraphQL */ `
486+
query SentimentGroup($id: ID!) {
487+
sentimentGroup(id: $id) {
488+
id
489+
name
490+
entities {
491+
entity
492+
name
493+
logo
494+
}
495+
}
496+
}
497+
`;
498+
499+
it('should return a group with nested entities', async () => {
500+
await con.getRepository(SentimentGroup).insert({
501+
id: '385404b4-f0f4-4e81-a338-bdca851eca31',
502+
name: 'Coding Agents',
503+
});
504+
await con.getRepository(SentimentEntity).insert([
505+
{
506+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
507+
entity: 'cursor',
508+
name: 'Cursor',
509+
logo: 'https://media.daily.dev/image/upload/public/cursor',
510+
},
511+
{
512+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
513+
entity: 'copilot',
514+
name: 'Copilot',
515+
logo: 'https://media.daily.dev/image/upload/public/copilot',
516+
},
517+
]);
518+
519+
const res = await client.query(QUERY, {
520+
variables: { id: '385404b4-f0f4-4e81-a338-bdca851eca31' },
521+
});
522+
523+
expect(res.errors).toBeFalsy();
524+
expect(normalizeGroup(res.data.sentimentGroup)).toEqual({
525+
id: '385404b4-f0f4-4e81-a338-bdca851eca31',
526+
name: 'Coding Agents',
527+
entities: [
528+
{
529+
entity: 'copilot',
530+
name: 'Copilot',
531+
logo: 'https://media.daily.dev/image/upload/public/copilot',
532+
},
533+
{
534+
entity: 'cursor',
535+
name: 'Cursor',
536+
logo: 'https://media.daily.dev/image/upload/public/cursor',
537+
},
538+
],
539+
});
540+
});
541+
542+
it('should return null when group does not exist', async () => {
543+
const res = await client.query(QUERY, {
544+
variables: { id: '385404b4-f0f4-4e81-a338-bdca851eca31' },
545+
});
546+
547+
expect(res.errors).toBeFalsy();
548+
expect(res.data.sentimentGroup).toBeNull();
549+
});
550+
551+
it('should return only entities under the requested group', async () => {
552+
await con.getRepository(SentimentGroup).insert([
553+
{
554+
id: '385404b4-f0f4-4e81-a338-bdca851eca31',
555+
name: 'Coding Agents',
556+
},
557+
{
558+
id: '970ab2c9-f845-4822-82f0-02169713b814',
559+
name: 'LLMs',
560+
},
561+
]);
562+
563+
await con.getRepository(SentimentEntity).insert([
564+
{
565+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
566+
entity: 'cursor',
567+
name: 'Cursor',
568+
logo: 'https://media.daily.dev/image/upload/public/cursor',
569+
},
570+
{
571+
groupId: '385404b4-f0f4-4e81-a338-bdca851eca31',
572+
entity: 'codex',
573+
name: 'Codex',
574+
logo: 'https://media.daily.dev/image/upload/public/openai',
575+
},
576+
{
577+
groupId: '970ab2c9-f845-4822-82f0-02169713b814',
578+
entity: 'gemini',
579+
name: 'Gemini',
580+
logo: 'https://media.daily.dev/image/upload/public/gemini',
581+
},
582+
]);
583+
584+
const res = await client.query(QUERY, {
585+
variables: { id: '385404b4-f0f4-4e81-a338-bdca851eca31' },
586+
});
587+
588+
expect(res.errors).toBeFalsy();
589+
expect(normalizeGroup(res.data.sentimentGroup)).toEqual({
590+
id: '385404b4-f0f4-4e81-a338-bdca851eca31',
591+
name: 'Coding Agents',
592+
entities: [
593+
{
594+
entity: 'codex',
595+
name: 'Codex',
596+
logo: 'https://media.daily.dev/image/upload/public/openai',
597+
},
598+
{
599+
entity: 'cursor',
600+
name: 'Cursor',
601+
logo: 'https://media.daily.dev/image/upload/public/cursor',
602+
},
603+
],
604+
});
605+
});
606+
});

src/entity/SentimentEntity.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {
2+
Column,
3+
CreateDateColumn,
4+
Entity,
5+
Index,
6+
JoinColumn,
7+
ManyToOne,
8+
PrimaryGeneratedColumn,
9+
Unique,
10+
} from 'typeorm';
11+
import type { SentimentGroup } from './SentimentGroup';
12+
13+
@Entity()
14+
@Index('IDX_sentiment_entity_group_id', ['groupId'])
15+
@Unique('UQ_sentiment_entity_entity', ['entity'])
16+
export class SentimentEntity {
17+
@PrimaryGeneratedColumn('uuid')
18+
id: string;
19+
20+
@Column({ type: 'uuid' })
21+
groupId: string;
22+
23+
@ManyToOne('SentimentGroup', (group: SentimentGroup) => group.entities, {
24+
lazy: true,
25+
onDelete: 'CASCADE',
26+
})
27+
@JoinColumn({ name: 'groupId' })
28+
group: Promise<SentimentGroup>;
29+
30+
@Column({ type: 'text' })
31+
entity: string;
32+
33+
@Column({ type: 'text' })
34+
name: string;
35+
36+
@Column({ type: 'text' })
37+
logo: string;
38+
39+
@CreateDateColumn()
40+
createdAt: Date;
41+
}

src/entity/SentimentGroup.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {
2+
Column,
3+
CreateDateColumn,
4+
Entity,
5+
OneToMany,
6+
PrimaryColumn,
7+
} from 'typeorm';
8+
import type { SentimentEntity } from './SentimentEntity';
9+
10+
@Entity()
11+
export class SentimentGroup {
12+
@PrimaryColumn({ type: 'uuid' })
13+
id: string;
14+
15+
@Column({ type: 'text' })
16+
name: string;
17+
18+
@OneToMany('SentimentEntity', (entity: SentimentEntity) => entity.group, {
19+
lazy: true,
20+
})
21+
entities: Promise<SentimentEntity[]>;
22+
23+
@CreateDateColumn()
24+
createdAt: Date;
25+
}

src/entity/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export * from './posts';
1313
export * from './PostReport';
1414
export * from './PostTag';
1515
export * from './Settings';
16+
export * from './SentimentEntity';
17+
export * from './SentimentGroup';
1618
export * from './Source';
1719
export * from './SquadPostsAnalytics';
1820
export * from './SourceDisplay';

0 commit comments

Comments
 (0)