Skip to content
Open
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
Binary file added backend/db.sqlite
Binary file not shown.
12,755 changes: 12,755 additions & 0 deletions backend/package-lock.json

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,34 @@
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/graphql": "^13.2.3",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"bcrypt": "^6.0.0",
"graphql": "^16.12.0",
"graphql-subscriptions": "3.0.0",
"graphql-ws": "^6.0.6",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.28",
"ws": "^8.18.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
Expand Down
43 changes: 43 additions & 0 deletions backend/scripts/subscribe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createClient } from 'graphql-ws';
import ws from 'ws';

const TOKEN =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImVtYWlsIjoiYUBhLmNvbSIsImlhdCI6MTc2NzU1ODQ2NywiZXhwIjoxNzY3NTYyMDY3fQ.DQ6PZbTt4hq7Vp1TwWE8nDwMCIEyhjvQAZMrmKxZrMs'; // replace with your JWT

const client = createClient({
url: 'ws://localhost:3200/graphql',
webSocketImpl: ws,
connectionParams: {
Authorization: `Bearer ${TOKEN}`,
},
});

const query = `subscription {
postAdded {
id
content
createdAt
author { id username email }
}
}`;

const dispose = client.subscribe(
{ query },
{
next: (data) => {
console.log('subscription data:', JSON.stringify(data, null, 2));
},
error: (err) => {
console.error('subscription error:', err);
},
complete: () => {
console.log('subscription complete');
},
},
);

// keep process alive (Ctrl+C to stop)
process.on('SIGINT', () => {
dispose();
process.exit();
});
34 changes: 34 additions & 0 deletions backend/scripts/ws-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const WebSocket = require('ws');
const { createClient } = require('graphql-ws');

const client = createClient({
url: 'ws://localhost:3200/graphql',
webSocketImpl: WebSocket, // <- provide ws implementation for Node
connectionParams: {
Authorization:
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjIsImVtYWlsIjoic2hpdmFtQGdtYWlsLmNvbSIsImlhdCI6MTc2NzU2NTY5OSwiZXhwIjoxNzY3NTY5Mjk5fQ.vTVzjozcYSuI8JbqGIWriYYrun82n2gUyrss4j1ceME',
},
});

const onNext = (data) => {
console.log('SUB NEXT', data);
};

const dispose = client.subscribe(
{
query:
'subscription { postAdded { id content createdAt author { id username } } }',
},
{
next: onNext,
error: (err) => console.error('SUB ERR', err),
complete: () => console.log('SUB complete'),
},
);

// keep alive for testing
setTimeout(() => {
dispose(); // stop after 2 minutes
console.log('disposed');
process.exit(0);
}, 120000);
29 changes: 28 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,40 @@ import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
import { HelloWorldResolver } from './hello-world/hello-world.resolver';
import { HelloWorldService } from './hello-world/hello-world.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users/users.module';
import { PostsModule } from './posts/posts.module';
import { AuthModule } from './auth/auth.module';

@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
autoSchemaFile: true,
// enable graphql-ws. onConnect is a good place to log/validate connectionParams (auth)
subscriptions: {
'graphql-ws': {
onConnect: (ctx) => {
console.log(
'WS onConnect connectionParams:',
(ctx as any).connectionParams,
);
return true;
},
onDisconnect: () => console.log('WS onDisconnect'),
},
},
// other options...
}),
TypeOrmModule.forRoot({
type: 'sqlite',
database: 'db.sqlite',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: true,
}),
UsersModule,
AuthModule,
PostsModule,
],
controllers: [AppController],
providers: [AppService, HelloWorldResolver, HelloWorldService],
Expand Down
19 changes: 19 additions & 0 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';

@Module({
imports: [
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET ?? 'devSecret',
signOptions: { expiresIn: '1h' },
}),
UsersModule,
],
providers: [JwtStrategy],
exports: [],
})
export class AuthModule {}
29 changes: 29 additions & 0 deletions backend/src/auth/gql-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// ...existing code...
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GqlExecutionContext } from '@nestjs/graphql';
import type { Request } from 'express';

// AuthGuard is a factory; cast the call result to a class constructor type.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const JwtAuthGuard = AuthGuard('jwt') as unknown as new (...args: any[]) => any;

@Injectable()
export class GqlAuthGuard extends JwtAuthGuard {
getRequest(context: ExecutionContext): Request {
const gqlCtx = GqlExecutionContext.create(context);
const ctx = gqlCtx.getContext<{ req?: Request } | undefined>();
const req = ctx?.req ?? context.switchToHttp().getRequest<Request>();

// DEBUG: log authorization header for this request
// remove after debugging

console.log(
'DBG Authorization header:',
req.headers?.authorization ?? '[none]',
);

return req;
}
}
// ...existing code...
50 changes: 50 additions & 0 deletions backend/src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// ...existing code...
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { UsersService } from '../users/users.service';
import { UserEntity } from '../users/user.entity';
import type { Request } from 'express';

// PassportStrategy is a factory; keep the cast to avoid the unsafe-call lint for the factory itself.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const BaseJwtStrategy = PassportStrategy(Strategy as unknown as any) as new (
...args: any[]
) => any;

@Injectable()
export class JwtStrategy extends BaseJwtStrategy {
constructor(private usersService: UsersService) {
super({
// use a typed manual extractor instead of ExtractJwt.fromAuthHeaderAsBearerToken()
jwtFromRequest: (req?: Request): string | null => {
if (!req) return null;
const authHeader = (req.headers as Record<string, string | undefined>)[
'authorization'
];
if (!authHeader) return null;
const [scheme, token] = authHeader.split(' ');
return scheme === 'Bearer' && token ? token : null;
},
secretOrKey: process.env.JWT_SECRET ?? 'devSecret',
});
}

async validate(payload: {
sub: number;
email?: string;
}): Promise<Omit<UserEntity, 'password'> | null> {
const user = await this.usersService.findById(payload.sub);
if (!user) return null;

const safeUser: Omit<UserEntity, 'password'> = {
id: user.id,
username: user.username,
email: user.email,
createdAt: user.createdAt,
};

return safeUser;
}
}
// ...existing code...
5 changes: 2 additions & 3 deletions backend/src/hello-world/hello-world.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Query, Resolver } from '@nestjs/graphql';
import { HelloWorldService } from "./hello-world.service";
import { HelloWorldModel } from "./model/HelloWorld.model";
import { HelloWorldService } from './hello-world.service';
import { HelloWorldModel } from './model/HelloWorld.model';

@Resolver()
export class HelloWorldResolver {

constructor(private helloWorldService: HelloWorldService) {}

@Query(() => HelloWorldModel)
Expand Down
4 changes: 2 additions & 2 deletions backend/src/hello-world/hello-world.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Injectable } from '@nestjs/common';
export class HelloWorldService {
async sayHello() {
return {
value: 'Hello World!'
}
value: 'Hello World!',
};
}
}
6 changes: 5 additions & 1 deletion backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: 'http://localhost:5177', // frontend dev URL (adjust if different)
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization'],
});
await app.listen(process.env.PORT ?? 3200);
console.log(`Graphql Endpoint: http://localhost:${process.env.PORT ?? 3200}/graphql`);
}
bootstrap();
34 changes: 34 additions & 0 deletions backend/src/posts/post.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ObjectType, Field, Int } from '@nestjs/graphql';
import { UserEntity } from '../users/user.entity';

@ObjectType()
@Entity('posts')
export class PostEntity {
@Field(() => Int)
@PrimaryGeneratedColumn()
id: number;

@Field()
@Column('text')
content: string;

@Field(() => UserEntity)
@ManyToOne(() => UserEntity)
@JoinColumn({ name: 'authorId' })
author: UserEntity;

@Column()
authorId: number;

@Field()
@CreateDateColumn({ type: 'datetime' })
createdAt: Date;
}
12 changes: 12 additions & 0 deletions backend/src/posts/posts.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PostEntity } from './post.entity';
import { PostsService } from './posts.service';
import { PostsResolver } from './posts.resolver';
import { UsersModule } from '../users/users.module';

@Module({
imports: [TypeOrmModule.forFeature([PostEntity]), UsersModule],
providers: [PostsService, PostsResolver],
})
export class PostsModule {}
54 changes: 54 additions & 0 deletions backend/src/posts/posts.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
Resolver,
Mutation,
Args,
Query,
Subscription,
Context,
} from '@nestjs/graphql';
import { UseGuards, UnauthorizedException } from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostEntity } from './post.entity';
import { GqlAuthGuard } from '../auth/gql-auth.guard';
import { pubSub } from '../pubsub';

interface GqlContext {
req: {
user?: {
id: number;
};
};
}

interface PostAddedPayload {
postAdded: PostEntity;
}

@Resolver(() => PostEntity)
export class PostsResolver {
constructor(private postsService: PostsService) {}

@Query(() => [PostEntity])
async posts() {
return this.postsService.findAll();
}

@UseGuards(GqlAuthGuard)
@Mutation(() => PostEntity)
async createPost(
@Args('content') content: string,
@Context() ctx: GqlContext,
) {
const user = ctx.req.user;
if (!user || typeof user.id !== 'number') {
throw new UnauthorizedException();
}
return this.postsService.create(content, user.id);
}

@Subscription(() => PostEntity, { resolve: (p) => p.postAdded ?? p })
postAdded() {
console.log('Subscription iterator created at', new Date().toISOString());
return pubSub.asyncIterator<PostEntity>('postAdded');
}
}
Loading